From 8b487cb316f70c2466adc17ac6265827b6a22847 Mon Sep 17 00:00:00 2001 From: Cubitrek IT Date: Thu, 8 Oct 2026 15:07:51 +0500 Subject: [PATCH 1/2] guard: a server that asks for more input has not acted, so the hold goes back Under MCP 2026-07-28 a server may answer tools/call with resultType "input_required" instead of acting; the client retries the same call under a new id. The guard took that answer for a success: it committed the reserved amount and marked any approval as used, then did both again on the retry. Now an input_required reply releases the hold with the reason execution.input-required, leaves the approval unspent, and the retry is decided on its own. Refusals carry resultType "complete" when the request named a protocol revision in _meta, so a 2026-07-28 client gets the shape it expects and an older client sees no change. Three tests, each shown to fail without the fix: the ledger ends with one released and one committed reservation and the daily headroom proves it; an approval survives the ask and is spent by the retry; a refusal carries resultType only for a client that announced the revision. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++ README.md | 2 + packages/verifier/bin/guard.mjs | 49 +++++++++---- packages/verifier/test/approvals.test.mjs | 53 ++++++++++++++ packages/verifier/test/fixtures/stub-mcp.mjs | 13 ++++ packages/verifier/test/guard-proxy.test.mjs | 73 ++++++++++++++++++++ 6 files changed, 180 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3360b..129e41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Covers the spec text, the JSON Schema, and `@cubitrek/agent-passport-verifier`. - A malformed `signature.value` now returns `signature.invalid` instead of throwing. - Passport fetches no longer follow redirects (`fetch.redirect`), are capped at 256 KB (`fetch.too-large`), and every network call times out after 10 seconds by default (`timeoutMs`). +### Fixed + +- **The guard counted a call that had not run.** A server on MCP 2026-07-28 may answer `tools/call` with `resultType: "input_required"` instead of acting; the client then retries the same call under a new id, carrying `inputResponses`. The guard treated that answer as a success: it committed the reserved amount and marked any approval as used, then did both again on the retry. Now the hold is handed back with the reason `execution.input-required`, the approval stays good, and the retry is decided on its own. Refusals also carry `resultType: "complete"` when the request named a protocol revision in `_meta`, so a 2026-07-28 client gets the shape it expects and an older one sees no change. + ### Added - `signAgentPassport()` and `dnsTxtRecord()` for issuers, and an `agent-passport` CLI. diff --git a/README.md b/README.md index 5229695..59cd6b9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ The policy needs a `tools` list, because only you know that `stripe.create_charg A 9,000 USD charge against that policy comes back to the agent as a refusal naming the cap, and the Stripe server never hears about it. A tool no rule covers is escalated rather than waved through, and a rule whose `amountFrom` finds no number is refused, because a call whose value cannot be read cannot be held to a limit. +Money is held when a call is decided and counted only once the server says it acted. A server error hands the amount back, and so does a server that asks the client for more input before acting (MCP 2026-07-28, `input_required`): the retry is a call of its own and is decided afresh. + The guard also annotates the tool list, so the model knows the rules before it tries. A worked policy with every field explained is in [`examples/policies`](./examples/policies). ### Or put it in front of Claude Code itself diff --git a/packages/verifier/bin/guard.mjs b/packages/verifier/bin/guard.mjs index e9c253a..29c312c 100644 --- a/packages/verifier/bin/guard.mjs +++ b/packages/verifier/bin/guard.mjs @@ -60,12 +60,16 @@ export async function runGuard({ const unmatched = policy.unmatched ?? "escalate"; const limits = limitsSummary(authority); - const refuse = (id, lines) => - send({ - jsonrpc: "2.0", - id, - result: { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], isError: true }, - }); + /** + * Refuse a call in the shape the client expects. From MCP 2026-07-28 every + * request names its protocol revision in `_meta` and every result carries + * `resultType`; an older client has never seen the field, so it gets none. + */ + const refuse = (message, lines) => { + const result = { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], isError: true }; + if (message.params?._meta?.["io.modelcontextprotocol/protocolVersion"] !== undefined) result.resultType = "complete"; + send({ jsonrpc: "2.0", id: message.id, result }); + }; async function guardCall(message) { const name = message.params?.name; @@ -78,7 +82,7 @@ export async function runGuard({ // The proxy has nobody to ask, so a rule wanting a person is a refusal // here, with the reason, rather than a prompt. log(`${name}: ${first.code}`); - return refuse(message.id, [ + return refuse(message, [ `Refused by the local policy: ${name}`, first.message, first.hint, @@ -88,7 +92,7 @@ export async function runGuard({ if (first.code === "tool.unmatched") { if (unmatched === "allow") return toUpstream(message); log(`${name}: no rule covers this tool, and unmatched is "${unmatched}"`); - return refuse(message.id, [ + return refuse(message, [ `Refused: no rule in the local policy covers "${name}".`, unmatched === "escalate" ? `A person has to decide whether this tool may be used${authority.humanInLoop ? ` (${authority.humanInLoop.escalation})` : ""}.` @@ -96,7 +100,7 @@ export async function runGuard({ ]); } log(`${name}: ${first.code}`); - return refuse(message.id, [`Refused: ${first.message}`, first.hint]); + return refuse(message, [`Refused: ${first.message}`, first.hint]); } const decision = await decide(authority, mapping.request, { ledger, engagementId }); @@ -119,7 +123,7 @@ export async function runGuard({ const pending = waiting?.request ?? buildApprovalRequest(decision, mapping.request); if (!waiting) approvals.ask(pending); log(`${name}: waiting for an answer, request ${pending.id}`); - return refuse(message.id, [ + return refuse(message, [ `Waiting for a person: ${name}`, ...decision.reasons.map((r) => r.message), ...found.errors.filter((e) => e.code !== "approval.none").map((e) => e.message), @@ -141,7 +145,7 @@ export async function runGuard({ if (!gate.ok) { const why = decision.reasons.map((r) => `${r.code}: ${r.message}${r.hint ? ` (${r.hint})` : ""}`); log(`${name}: ${decision.decision}, ${decision.reasons.map((r) => r.code).join(", ")}`); - return refuse(message.id, [ + return refuse(message, [ `Refused by the local policy: ${name}`, ...why, decision.escalation @@ -194,7 +198,7 @@ export async function runGuard({ // One call the guard could not work out must not take the guard down // with it, and must not be forwarded either. Refuse it and carry on. log(`${message.params?.name}: ${err?.message ?? err}`); - refuse(message.id, [ + refuse(message, [ `Refused: the guard could not decide this call.`, String(err?.message ?? err), "This call was not sent to the server.", @@ -225,9 +229,26 @@ export async function runGuard({ // the held amount goes back. A connection that dies instead is handled // on exit, where the outcome is genuinely unknown. const refused = message.error !== undefined || message.result?.isError === true; - await (refused ? owed.hold.release() : owed.hold.commit()); + // A server on MCP 2026-07-28 may instead ask for more input before + // acting (resultType "input_required"). Nothing ran. The client will + // retry the same call under a new id and that retry is decided on its + // own, so the held amount goes back now rather than being counted twice. + const deferred = !refused && message.result?.resultType === "input_required"; + if (refused) { + await owed.hold.release(); + } else if (deferred) { + log(`${owed.name}: the server asked for more input first, so the call has not run`); + await owed.hold.release([ + { + code: "execution.input-required", + message: "The server asked for more input before acting, so this call did not run. The retry is decided on its own.", + }, + ]); + } else { + await owed.hold.commit(); + } // An approval buys one call. Spend it only when the call actually ran. - if (owed.approved && !refused && approvals) { + if (owed.approved && !refused && !deferred && approvals) { approvals.answer({ context: owed.approved.context, kind: "answer", diff --git a/packages/verifier/test/approvals.test.mjs b/packages/verifier/test/approvals.test.mjs index d250339..999eb0c 100644 --- a/packages/verifier/test/approvals.test.mjs +++ b/packages/verifier/test/approvals.test.mjs @@ -275,3 +275,56 @@ test("without --approvals an escalation is still simply refused", () => { rmSync(dir, { recursive: true, force: true }); } }); + +/** Run one guard session with several tool calls, returning the replies by id. */ +function session(dir, params, { policy = POLICY } = {}) { + writeFileSync(join(dir, "policy.json"), JSON.stringify(policy)); + const messages = [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "c", version: "1" } } }, + ...params.map((p, i) => ({ jsonrpc: "2.0", id: 10 + i, method: "tools/call", params: p })), + ]; + const proc = spawnSync(process.execPath, [ + cli, "guard", "--policy", join(dir, "policy.json"), + "--ledger", join(dir, "spend.jsonl"), "--approvals", join(dir, "approvals.jsonl"), + "--", process.execPath, stub, + ], { input: `${messages.map((m) => JSON.stringify(m)).join("\n")}\n`, encoding: "utf8", timeout: 30_000, env: { ...process.env, STUB_LOG: join(dir, "stub.log") } }); + const replies = new Map(); + for (const line of (proc.stdout || "").trim().split("\n")) { + if (!line.trim()) continue; + const m = JSON.parse(line); + replies.set(m.id, m); + } + return replies; +} + +test("a server that asks for more input first does not spend the approval", () => { + const dir = temp(); + try { + // 2,500 needs a person. Once approved, the server (MCP 2026-07-28) asks + // the client for more input instead of acting. Nothing ran, so the + // approval must still be good for the retry, and spent by it. One session + // per step, so each reply has landed before the next call is decided. + const args = { amount: 250_000, customer: "cus_ask", ask: true }; + const answered = { confirm: { action: "accept", content: { ok: true } } }; + const status = () => JSON.parse(run("approvals", "--approvals", join(dir, "approvals.jsonl"), "--all", "--json").stdout).map((s) => s.status); + + const waitingReply = session(dir, [{ name: "stripe.create_charge", arguments: args }]); + assert.equal(waitingReply.get(10).result?.isError, true, "first it waits for a person"); + run("approve", waiting(dir)[0].request.id, "--approvals", join(dir, "approvals.jsonl"), "--by", "faizan"); + assert.deepEqual(status(), ["approved"]); + + const asked = session(dir, [{ name: "stripe.create_charge", arguments: args }]); + assert.equal(asked.get(10).result?.resultType, "input_required", "the server asked instead of acting"); + assert.deepEqual(status(), ["approved"], "nothing ran, so the approval is not spent"); + + const ran = session(dir, [{ name: "stripe.create_charge", arguments: args, inputResponses: answered, requestState: "stub-state" }]); + assert.match(ran.get(10).result?.content?.[0]?.text ?? "", /did stripe\.create_charge/, "the retry is covered by the approval"); + assert.deepEqual(status(), ["used"], "and spent by the call that ran"); + + const again = session(dir, [{ name: "stripe.create_charge", arguments: args, inputResponses: answered, requestState: "stub-state" }]); + assert.equal(again.get(10).result?.isError, true); + assert.match(again.get(10).result?.content?.[0]?.text ?? "", /already been used/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/verifier/test/fixtures/stub-mcp.mjs b/packages/verifier/test/fixtures/stub-mcp.mjs index 618e8f9..30b2464 100644 --- a/packages/verifier/test/fixtures/stub-mcp.mjs +++ b/packages/verifier/test/fixtures/stub-mcp.mjs @@ -27,6 +27,19 @@ for await (const line of rl) { if (LOG) appendFileSync(LOG, `${JSON.stringify({ name: message.params.name, args: message.params.arguments })}\n`); if (message.params.name === "stripe.refund") { reply({ content: [{ type: "text", text: "refund failed at the provider" }], isError: true }); + } else if (message.params.arguments?.ask === true && message.params.inputResponses === undefined) { + // MCP 2026-07-28: ask the client for more input instead of acting. The + // client retries the same call, under a new id, carrying inputResponses. + reply({ + resultType: "input_required", + inputRequests: { + confirm: { + method: "elicitation/create", + params: { mode: "form", message: "Confirm?", requestedSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] } }, + }, + }, + requestState: "stub-state", + }); } else { reply({ content: [{ type: "text", text: `did ${message.params.name}` }] }); } diff --git a/packages/verifier/test/guard-proxy.test.mjs b/packages/verifier/test/guard-proxy.test.mjs index e8e8c1b..f02f16c 100644 --- a/packages/verifier/test/guard-proxy.test.mjs +++ b/packages/verifier/test/guard-proxy.test.mjs @@ -310,3 +310,76 @@ test("a policy with limits and no ledger says so at startup, not at the first ca rmSync(dir, { recursive: true, force: true }); } }); + +test("a server that asks for more input did not act, so nothing counts until the retry runs", () => { + const dir = temp(); + try { + // MCP 2026-07-28 lets a server answer tools/call with resultType + // "input_required" instead of acting. The client then retries the same + // call under a new id, carrying inputResponses and requestState. The first + // attempt moved no money, so it must not be counted; the retry is a call + // of its own and is decided afresh. + const ask = { amount: 190_000, customer: "cus_ask", ask: true }; // 1,900 USD + const retry = { + jsonrpc: "2.0", id: 11, method: "tools/call", + params: { name: "stripe.create_charge", arguments: ask, inputResponses: { confirm: { action: "accept", content: { ok: true } } }, requestState: "stub-state" }, + }; + const first = runGuard(dir, { + messages: [call(10, "stripe.create_charge", ask), retry], + ledger: "spend.jsonl", + receipts: "receipts.jsonl", + reset: true, + }); + assert.equal(first.replies.get(10).result?.resultType, "input_required", "the ask reaches the client untouched"); + assert.equal(first.replies.get(10).result?.requestState, "stub-state"); + assert.equal(blocked(first.replies.get(11)), false, "the answered retry goes through"); + assert.deepEqual(first.received.map((r) => r.args.customer), ["cus_ask", "cus_ask"]); + + // One reservation was handed back, one was kept. + const entries = readFileSync(join(dir, "spend.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + assert.deepEqual(entries.map((e) => e.state).sort(), ["committed", "released", "reserved", "reserved"]); + + // The trail says so: the first attempt did not run, and names why. + const receipts = JSON.parse(spawnSync(process.execPath, [cli, "log", "--receipts", join(dir, "receipts.jsonl"), "--json"], { encoding: "utf8" }).stdout); + assert.deepEqual(receipts.map((r) => r.outcome).sort(), ["blocked", "executed"]); + assert.deepEqual(receipts.find((r) => r.outcome === "blocked").blockedBecause, ["execution.input-required"]); + + // And only 1,900 of the 5,000 is gone: a second 1,900 fits, a third does not. + const second = runGuard(dir, { + messages: [ + call(12, "stripe.create_charge", { amount: 190_000, customer: "cus_3" }), // 3,800 so far + call(13, "stripe.create_charge", { amount: 190_000, customer: "cus_4" }), // 5,700: over the cap + ], + ledger: "spend.jsonl", + }); + assert.equal(blocked(second.replies.get(12)), false, "the attempt that did not run must not have been counted"); + assert.equal(blocked(second.replies.get(13)), true, "and the cap still holds once the money is really spent"); + assert.match(text(second.replies.get(13)), /amount\.above-ceiling/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a refusal carries resultType only when the client speaks MCP 2026-07-28", () => { + const dir = temp(); + try { + // From 2026-07-28 every request names its protocol revision in _meta and + // every result carries resultType. An older client has never seen the + // field, so a refusal to it keeps the shape it knows. + const meta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "t", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }; + const newer = { jsonrpc: "2.0", id: 20, method: "tools/call", params: { name: "stripe.create_charge", arguments: { amount: 900_000, customer: "c" }, _meta: meta } }; + const older = call(21, "stripe.create_charge", { amount: 900_000, customer: "c" }); + const { replies, received } = runGuard(dir, { messages: [newer, older], ledger: "spend.jsonl", reset: true }); + assert.equal(received.length, 0, "both are over the cap and never reach the server"); + assert.equal(blocked(replies.get(20)), true); + assert.equal(replies.get(20).result.resultType, "complete"); + assert.equal(blocked(replies.get(21)), true); + assert.equal("resultType" in replies.get(21).result, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 866841003b9f8f195c72742d1aa2bc034828b205 Mon Sep 17 00:00:00 2001 From: Cubitrek IT Date: Thu, 8 Oct 2026 15:11:14 +0500 Subject: [PATCH 2/2] Name the enforcement side Passport Guard, lead with authority, rename the package The README opened with "verifiable, business-issued identity and authority". Identity for agents on the web is being standardised elsewhere (IETF Web Bot Auth, over HTTP Message Signatures) and its charter leaves authority out of scope, so the passport is now described as what it is: a business's signed statement of what its agents may commit, with Passport Guard enforcing that statement inside the business's own agents and receipts proving it. The package becomes @cubitrek/agent-passport. It carries the verifier, the signer, the guard, the Claude Code hook and the approvals tooling, so a name that said only "verifier" misdescribed it; it has not been published under either name. The /node subpath moves with it. "Agent passport" is a generic phrase with at least three other users, so the README now says which of them this is not, from their own pages. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 6 ++- README.md | 68 +++++++++++++++--------- SECURITY.md | 2 +- packages/verifier/README.md | 27 +++++----- packages/verifier/bin/agent-passport.mjs | 7 +-- packages/verifier/bin/mcp.mjs | 2 +- packages/verifier/package-lock.json | 4 +- packages/verifier/package.json | 11 +++- packages/verifier/src/index.ts | 4 +- packages/verifier/src/ledger-node.ts | 2 +- packages/verifier/src/ledger.ts | 2 +- packages/verifier/src/node.ts | 2 +- spec/agent-passport-v0.1.md | 6 ++- spec/proposals/local-policy.md | 4 +- 15 files changed, 90 insertions(+), 59 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfbb751..e6c1372 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release -# Publishes @cubitrek/agent-passport-verifier when a tag like verifier-v0.1.2 is pushed. +# Publishes @cubitrek/agent-passport when a tag like verifier-v0.1.2 is pushed. # Needs the NPM_TOKEN repository secret (an npm automation token with publish rights on @cubitrek). on: diff --git a/CHANGELOG.md b/CHANGELOG.md index 129e41b..5ddcb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -Covers the spec text, the JSON Schema, and `@cubitrek/agent-passport-verifier`. +Covers the spec text, the JSON Schema, and `@cubitrek/agent-passport`. ## 0.1.2 (unreleased) @@ -38,7 +38,7 @@ Covers the spec text, the JSON Schema, and `@cubitrek/agent-passport-verifier`. - Tool rules. A policy's `tools` list maps tool names to scopes and says where the value lives (`amountFrom`, `amountUnit`, `targetFrom`), because nothing else can know that a charge of 800000 means 8,000 USD. The first matching rule wins, `*` is a wildcard, and `unmatched` decides what happens to a tool no rule covers (`escalate` by default, or `deny`, or `allow`). A rule that names `amountFrom` and finds no number there is refused rather than passed through unpriced. Rules are validated when the policy loads, so a rule naming a scope the policy does not grant fails at startup. - `guard` refuses to start when the policy sets a limit and no `--ledger` is given. Previously that combination refused every priced call at run time with a confusing reason. - `fileReceiptSink()` and `readReceipts()`, on the `/node` subpath, which is now `dist/node.js` and re-exports `fileSpendLedger`. -- Local policy and counted ceilings. Authority is now separate from where it came from: `decide()` takes an `Authority`, `passportAuthority()` reads one out of a verified passport, `localPolicy()` builds one from rules the operator wrote, and `intersect()` returns what both grant and nothing more. `authorize()` is `decide()` with a passport in front of it, unchanged for callers. A `SpendLedger` (`memorySpendLedger()`, and `fileSpendLedger()` from the new `@cubitrek/agent-passport-verifier/node` subpath, which keeps `node:fs` out of the main entry so the package still runs in Workers and browsers) counts what a subject has committed over an engagement, a UTC day, a UTC month or all time; an allow reserves its amount so two decisions taken before either executes cannot both spend it. A cap wider than one engagement with nothing counting it now escalates (`amount.cumulative-unknown`) rather than passing. The CLI gains `authorize --policy --ledger --engagement` and a `settle` command; `agent-passport mcp --policy` enforces the operator's policy on every decision, and nothing the model sends can widen it. See `spec/proposals/local-policy.md` and threat model §1.10, §1.11. +- Local policy and counted ceilings. Authority is now separate from where it came from: `decide()` takes an `Authority`, `passportAuthority()` reads one out of a verified passport, `localPolicy()` builds one from rules the operator wrote, and `intersect()` returns what both grant and nothing more. `authorize()` is `decide()` with a passport in front of it, unchanged for callers. A `SpendLedger` (`memorySpendLedger()`, and `fileSpendLedger()` from the new `@cubitrek/agent-passport/node` subpath, which keeps `node:fs` out of the main entry so the package still runs in Workers and browsers) counts what a subject has committed over an engagement, a UTC day, a UTC month or all time; an allow reserves its amount so two decisions taken before either executes cannot both spend it. A cap wider than one engagement with nothing counting it now escalates (`amount.cumulative-unknown`) rather than passing. The CLI gains `authorize --policy --ledger --engagement` and a `settle` command; `agent-passport mcp --policy` enforces the operator's policy on every decision, and nothing the model sends can widen it. See `spec/proposals/local-policy.md` and threat model §1.10, §1.11. - The guard. `guardedCall()` is the one place a side effect happens: it checks the decision against the values about to be executed, settles the amount against the ledger, and writes a receipt either way. `checkAndHold()` is the same gate in two steps. An effect that throws is recorded as `unknown` and its reservation committed by default, because over-counting only makes the next decision more cautious. - Receipts. `buildReceipt()`, `signReceipt()` and `verifyReceipt()` produce a signed record of who acted, under which authority, what was decided and why, the binding digest, and what became of it. A receipt carries the tool name, the scope and the amount, and never the target or the arguments: the digest already covers those, so a receipt can be handed to an auditor without disclosing the call. - `execution.decision-altered`: a decision whose `agentId`, `issuerDomain` or `keyId` no longer matches the subject it was issued for. Previously this surfaced as `execution.request-changed`. @@ -63,6 +63,8 @@ Covers the spec text, the JSON Schema, and `@cubitrek/agent-passport-verifier`. ### Changed +- **The package is `@cubitrek/agent-passport`** (it was `@cubitrek/agent-passport-verifier`; neither name has been published yet). It carries the verifier, the signer, Passport Guard, the Claude Code hook and the approvals tooling, so a name that said only "verifier" misdescribed it. The `/node` subpath moves with it. The bare npm name `agent-passport` belongs to someone else. +- **The project describes itself as a statement of authority, not of identity.** README and spec §1: who is calling is established separately (the IETF Web Bot Auth work over HTTP Message Signatures) and a passport is read for what that caller's business has committed to. The enforcement side has a name, Passport Guard, and the README says which other things called "agent passport" this is not. - Domain comparison ignores case and a trailing dot. - Canonical JSON, used for signing and for request digests, refuses values with no unambiguous JSON form (Dates, NaN, bigints) instead of writing them as `{}` or `null`. - Schema: `authority.decisionAudit` must contain `{engagementId}`, as the spec already required. diff --git a/README.md b/README.md index 59cd6b9..6806a63 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # Agent Passport -> A standard for verifiable, business-issued identity and authority for AI agents that talk to other AI agents across organisational boundaries. +> Agent Passport is how a business states what its AI agents may commit, and proves it enforced that statement. -A business publishes one signed JSON file at `/.well-known/agent-passport.json`. It tells another business's agent **who** the agent acts for, **what it may do**, **how much it can commit**, **when a human takes over**, and **where the audit trail lives**. The signing key sits in the business's own DNS, so anyone can check a passport without asking Cubitrek or any other third party. It is the trust and authority layer that sits on top of MCP and A2A. +The **passport** is the public statement: one signed JSON file at `/.well-known/agent-passport.json` that tells another business's agent **who** the agent acts for, **what it may do**, **how much it can commit**, **when a human takes over**, and **where the audit trail lives**. The signing key sits in the business's own DNS, so anyone can check it without asking Cubitrek or any other third party. + +**Passport Guard** is the enforcement: the same decision engine, run against a local policy inside your own agents. It decides every tool call before it runs, counts spend against the ceilings, holds anything that needs a person until someone named signs off, and leaves signed **receipts** that say what happened without carrying the payload. + +Who is calling is a separate question, answered by caller binding such as the IETF's [Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/) work; a passport composes with it. It sits beside MCP and A2A rather than above them. - **Canonical spec:** [`spec/agent-passport-v0.1.md`](./spec/agent-passport-v0.1.md) (draft v0.1, 2026-04-28), with a [threat model](./spec/threat-model.md) - **Authored by:** [Cubitrek](https://cubitrek.com) @@ -14,18 +18,18 @@ A business publishes one signed JSON file at `/.well-known/agent-passport.json`. | --- | --- | | Running an agent that deals with other companies | [Issue a passport](#issue-a-passport-for-your-agent) | | Receiving agent traffic: an API, an MCP server, a sales or procurement flow | [Decide what an inbound agent may do](#decide-what-an-inbound-agent-may-do) | -| Running your own agent and wanting limits on it | [Set limits on your own agent](#set-limits-on-your-own-agent) | +| Running your own agent and wanting limits on it | [Passport Guard: set limits on your own agent](#passport-guard-set-limits-on-your-own-agent) | | Using Claude, Cursor or another MCP client | [Give your AI assistant the tools](#give-your-ai-assistant-the-tools) | | Responsible for a published passport | [Keep it healthy](#keep-it-healthy) | -The commands below use `npx`. After `npm install -g @cubitrek/agent-passport-verifier` you can type `agent-passport` directly. +The commands below use `npx`. After `npm install -g @cubitrek/agent-passport` you can type `agent-passport` directly. > **Not on npm yet.** 0.1.2 publishes when the release workflow runs. Until then, clone this repository and run `npm install && npm run build` in `packages/verifier`, and call the CLI as `node packages/verifier/bin/agent-passport.mjs`. ## Issue a passport for your agent ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport init +npx -p @cubitrek/agent-passport agent-passport init ``` `init` asks about your company and your agent: domain, legal name, what the agent does, where counterparties reach it, what it may do, how much it may commit, and who takes over. It then writes: @@ -37,13 +41,13 @@ npx -p @cubitrek/agent-passport-verifier agent-passport init It finishes by printing the one DNS TXT record to add. Upload the two files so they are served from your domain's `/.well-known/`, add the record, then check the result the way a counterparty will: ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport doctor yourdomain.example +npx -p @cubitrek/agent-passport agent-passport doctor yourdomain.example ``` Every question has a flag, so `init` also runs unattended in scripts and CI (`agent-passport help init`). Before the passport expires, re-issue it: ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport renew .well-known/agent-passport.json --key ~/.agent-passport/keys/yourdomain-2026-q3.pem +npx -p @cubitrek/agent-passport agent-passport renew .well-known/agent-passport.json --key ~/.agent-passport/keys/yourdomain-2026-q3.pem ``` `renew` also confirms your DNS record still carries the key. @@ -51,11 +55,11 @@ npx -p @cubitrek/agent-passport-verifier agent-passport renew .well-known/agent- ## Decide what an inbound agent may do ```bash -npm install @cubitrek/agent-passport-verifier +npm install @cubitrek/agent-passport ``` ```typescript -import { authorize, checkExecution, verifyAgentPassport } from "@cubitrek/agent-passport-verifier"; +import { authorize, checkExecution, verifyAgentPassport } from "@cubitrek/agent-passport"; const verification = await verifyAgentPassport({ domain: "acme.example" }); const decision = await authorize(verification, { @@ -83,7 +87,7 @@ switch (decision.decision) { Each decision is also bound to the exact request, including the concrete tool, target and arguments, and expires after 60 seconds (or the issuer's response window for an escalation). Whatever performs the side effect goes through the guard, with the final values: ```typescript -import { guardedCall } from "@cubitrek/agent-passport-verifier"; +import { guardedCall } from "@cubitrek/agent-passport"; const result = await guardedCall(decision, finalRequest, () => provider.transfer(finalRequest), { ledger, @@ -100,7 +104,7 @@ It refuses if the target or arguments changed, the decision expired, it was a de A passport is public, so verifying one proves what the issuer authorised, not who is contacting you. When the issuer publishes request-signing keys in its passport, check the signature on the request itself: ```typescript -import { verifyAgentCaller } from "@cubitrek/agent-passport-verifier"; +import { verifyAgentCaller } from "@cubitrek/agent-passport"; const caller = await verifyAgentCaller(request, verification.passport, { nonceStore }); if (!caller.ok) return reject(caller.errors); @@ -109,7 +113,7 @@ if (!caller.ok) return reject(caller.errors); That verifies a standard HTTP Message Signature (RFC 9421) over the method, host, path, query and body against the keys the passport publishes, and refuses one that is stale, replayed, made with an unpublished key, or that leaves part of the request uncovered. On the agent side, one call signs the request: ```typescript -import { signAgentRequest } from "@cubitrek/agent-passport-verifier"; +import { signAgentRequest } from "@cubitrek/agent-passport"; const headers = await signAgentRequest({ method: "POST", url, body }, { keyId, privateKey }); ``` @@ -119,14 +123,14 @@ Issuers create the key with `agent-passport init --request-key`, or add one to a From a shell or a script: ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport authorize acme.example --scope procurement.purchase --amount 42000 +npx -p @cubitrek/agent-passport agent-passport authorize acme.example --scope procurement.purchase --amount 42000 ``` It exits 0 for allow, 2 for escalate and 1 for deny. Add `--json` for the full decision. Without one of those bindings, a valid passport still only proves what the issuer authorised, not who is calling; see spec §7, "What verification proves". -## Set limits on your own agent +## Passport Guard: set limits on your own agent The same envelope works with no counterparty in sight, and this half needs nobody else to adopt anything. @@ -135,7 +139,7 @@ The same envelope works with no counterparty in sight, and this half needs nobod `guard` runs an MCP server behind your policy. Every tool call is decided first, and a call the policy refuses is never forwarded, so the agent cannot go around it: ```bash -claude mcp add stripe -- npx -p @cubitrek/agent-passport-verifier agent-passport guard \ +claude mcp add stripe -- npx -p @cubitrek/agent-passport agent-passport guard \ --policy ~/.agent-passport/treasury.json \ --ledger ~/.agent-passport/spend.jsonl \ --receipts ~/.agent-passport/receipts.jsonl \ @@ -236,7 +240,7 @@ Worth being blunt about who may answer: an agent that can run a shell can run `a ### See what it has been doing ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport log --receipts ~/.agent-passport/receipts.jsonl +npx -p @cubitrek/agent-passport agent-passport log --receipts ~/.agent-passport/receipts.jsonl ``` ``` @@ -263,7 +267,7 @@ Write the rules down and the decision engine applies them exactly as it applies ``` ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport authorize \ +npx -p @cubitrek/agent-passport agent-passport authorize \ --policy treasury.json --ledger spend.jsonl \ --scope payments.transfer --amount 400 --tool payments.create_transfer ``` @@ -271,7 +275,7 @@ npx -p @cubitrek/agent-passport-verifier agent-passport authorize \ `--ledger` keeps the running total, so a cap over a day, a month or a whole engagement is counted rather than merely published. An allow holds its amount until you close it out: ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport settle --ledger spend.jsonl --commit +npx -p @cubitrek/agent-passport agent-passport settle --ledger spend.jsonl --commit ``` A hold that is never settled lapses when the decision expires, so a crashed run frees its own headroom. Without a ledger, a cap wider than a single engagement escalates instead of passing unchecked: nothing is counting it, so it is a question for a person rather than a quiet yes. @@ -279,7 +283,7 @@ A hold that is never settled lapses when the decision expires, so a crashed run Pass a domain **and** a policy to run both at once. Scopes intersect, every ceiling is enforced, and the lower human threshold wins, so what a counterparty published is a maximum it will be held to, never permission to exceed your own rules: ```bash -npx -p @cubitrek/agent-passport-verifier agent-passport authorize acme.example \ +npx -p @cubitrek/agent-passport agent-passport authorize acme.example \ --policy treasury.json --scope payments.transfer --amount 5000 ``` @@ -296,7 +300,7 @@ A receipt carries no payload. The tool name, the scope and the amount are in it; Claude Code: ```bash -claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport-verifier agent-passport mcp +claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport agent-passport mcp ``` Claude Desktop, Cursor and other MCP clients: @@ -306,7 +310,7 @@ Claude Desktop, Cursor and other MCP clients: "mcpServers": { "agent-passport": { "command": "npx", - "args": ["-y", "-p", "@cubitrek/agent-passport-verifier", "agent-passport", "mcp"] + "args": ["-y", "-p", "@cubitrek/agent-passport", "agent-passport", "mcp"] } } } @@ -364,7 +368,7 @@ agent-passport/ execution-boundary/ # Harness: one action, many executions, three authorities end-to-end/ # Two companies, one purchase, the whole chain in one run packages/ - verifier/ # @cubitrek/agent-passport-verifier: library, CLI, MCP server + verifier/ # @cubitrek/agent-passport: library, CLI, MCP server action.yml # GitHub Action: scheduled health check ``` @@ -388,6 +392,10 @@ OAuth authenticates a caller against one provider that issued the token. A passp An Agent Card answers "what can this agent do". A passport answers "what is this agent authorised to commit to on behalf of which business, and who takes over when it should not decide alone". Agent Passport is additive to A2A and MCP, not a replacement for either. +### Is this the same as Trulioo's Digital Agent Passport, or the Open Agent Passport? + +No. Those are a merchant-facing know-your-agent credential and a tool-call authorization gate. An Agent Passport is the statement a business publishes about its own agents, under its own domain, and Passport Guard is this project's gate. The differences are set out under [Not to be confused with](#not-to-be-confused-with). + ### What stops someone forging a passport? The signing key has to live inside the issuer's own DNS zone. A passport that names `acme.example` as issuer but points at a key in a zone the forger controls is refused before that zone is ever queried. Controlling the domain is the root of trust, which is the same thing a TLS certificate proves. @@ -425,13 +433,23 @@ Agent Passport is **additive**. It does not replace anything. | Agent-to-tool, same org | Model Context Protocol (MCP) | | Agent-to-agent transport | Agent2Agent (A2A) Agent Card | | Capability manifest | `agents.json` (Wildcard), A2A skills | -| **Identity, authority, audit, escalation** | **Agent Passport (this spec)** | +| Caller identity over HTTP | Web Bot Auth (IETF), HTTP Message Signatures | +| **Authority, audit, escalation** | **Agent Passport (this spec)** | - **A2A `/.well-known/agent-card.json`** answers "what can this agent do." Agent Passport answers "what is this agent authorised to commit to on behalf of which business." -- **MCP** is about a single agent-tool boundary. Agent Passport is about cross-organisational trust. +- **MCP** is about a single agent-tool boundary. A passport is about what a business has committed to across organisations; Passport Guard sits at the MCP boundary and enforces it. - **OpenAPI** describes HTTP surface. Agent Passport describes commercial surface. - **Hosted agent registries and runtime guardrails** decide inside one platform. A published passport works across companies with no shared platform, and those products can read it as an input. -- **W3C Verifiable Credentials** are a primitive Agent Passport can lean on for stronger identity proofs in v0.2. +- **Web Bot Auth** (IETF) establishes who is calling, over HTTP Message Signatures, and leaves what the caller may do out of scope by charter. A passport is that other half, and composes with it. + +### Not to be confused with + +"Agent passport" is a generic phrase, and as of October 2026 at least three other projects use it. None of them is this one, and this one does not set out to replace them. + +- [Trulioo Digital Agent Passport](https://www.businesswire.com/news/home/20250814588043/en) is a know-your-agent credential, issued through Trulioo, that tells a merchant inside a payment flow who built an agent and that the user consented. An Agent Passport is published by the business the agent acts for, under that business's own domain, with no issuer in between. +- [Open Agent Passport (APort)](https://github.com/aporthq/aport-spec) is a pre-action authorization gate with its own passport, decision and proof schemas. That is the same layer as Passport Guard. It has no statement a business publishes for counterparties to read. +- [`@scopeblind/passport`](https://www.npmjs.com/package/@scopeblind/passport) is an agent-side identity and manifest signer beside `protect-mcp`, a Cedar policy gate with signed receipts. Same layer as Passport Guard again. Its receipt format is the [Acta signed-receipts draft](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/), which this project intends to export to rather than compete with. +- [`agents.txt`](https://github.com/kaylacar/agents-txt) and [`agent-permissions.json`](https://github.com/las-wg/agent-permissions.json) say what a website lets visiting agents do. A passport says what a business lets its own agents do elsewhere. ## Versioning diff --git a/SECURITY.md b/SECURITY.md index 76e6052..7e009b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ We acknowledge reports within 5 business days, agree a disclosure date with you, - The spec text in `spec/`, including places where the spec claims a protection it does not deliver. - The JSON Schema in `schemas/`. -- `@cubitrek/agent-passport-verifier` and its `agent-passport` CLI. +- `@cubitrek/agent-passport` and its `agent-passport` CLI. A specific company's deployment (its passport, DNS records or keys) is out of scope here. Report those to the issuer through `issuer.contact` in their passport. diff --git a/packages/verifier/README.md b/packages/verifier/README.md index 9f9a951..47a2081 100644 --- a/packages/verifier/README.md +++ b/packages/verifier/README.md @@ -1,17 +1,18 @@ -# @cubitrek/agent-passport-verifier +# @cubitrek/agent-passport -Library, CLI and MCP server for the [Agent Passport spec, v0.1](https://github.com/Cubitrek/agent-passport/blob/main/spec/agent-passport-v0.1.md). +Library, CLI, Passport Guard and MCP server for the [Agent Passport spec, v0.1](https://github.com/Cubitrek/agent-passport/blob/main/spec/agent-passport-v0.1.md). - **Verify** a passport: schema, the signing key inside the issuer's own DNS zone, the Ed25519 signature, the validity window and the revocation list. - **Authorize** one request against it: allow, escalate to the issuer's human, or deny. +- **Guard** your own agents (Passport Guard): `agent-passport guard` proxies an MCP server and `agent-passport hook` sits in Claude Code; both decide every tool call against a local policy, count spend, hold for a signed approval and write receipts. - **Issue and renew** passports without hand-editing JSON. - **Health-check** a published passport, with a fix for every problem. - **Connect AI assistants** through a four-tool MCP server. -The library is pure ESM and runs in Node 20+, Cloudflare Workers and modern browsers. The CLI and MCP server need Node, as does the `@cubitrek/agent-passport-verifier/node` subpath, which is where anything touching the file system lives so the main entry stays portable. +The library is pure ESM and runs in Node 20+, Cloudflare Workers and modern browsers. The CLI and MCP server need Node, as does the `@cubitrek/agent-passport/node` subpath, which is where anything touching the file system lives so the main entry stays portable. ```bash -npm install @cubitrek/agent-passport-verifier +npm install @cubitrek/agent-passport ``` > **Not on npm yet.** 0.1.2 publishes when the release workflow runs. Until then, clone this repository and run `npm install && npm run build` in `packages/verifier`, and call the CLI as `node packages/verifier/bin/agent-passport.mjs`. @@ -41,7 +42,7 @@ npm install @cubitrek/agent-passport-verifier ## Verify, then authorize ```typescript -import { authorize, guardedCall, memoryNonceStore, verifyAgentPassport } from "@cubitrek/agent-passport-verifier"; +import { authorize, guardedCall, memoryNonceStore, verifyAgentPassport } from "@cubitrek/agent-passport"; const verification = await verifyAgentPassport({ domain: "acme.example" }); @@ -77,7 +78,7 @@ The binding is unsigned, so it protects where the component that decides and the `decide()` is the engine, and it takes an `Authority`: the envelope of scopes, ceilings, human threshold, counterparty rules and compliance, plus the subject it applies to. `authorize()` is `decide()` with `passportAuthority()` in front of it. ```typescript -import { decide, intersect, localPolicy, passportAuthority } from "@cubitrek/agent-passport-verifier"; +import { decide, intersect, localPolicy, passportAuthority } from "@cubitrek/agent-passport"; const mine = localPolicy({ id: "treasury-local", @@ -101,8 +102,8 @@ await decide(intersect(passportAuthority(verification), mine), request, { ledger A ceiling nothing counts is a statement of intent. A `SpendLedger` records what a subject has committed, over one engagement, a UTC day, a UTC month or all time. ```typescript -import { memorySpendLedger } from "@cubitrek/agent-passport-verifier"; -import { fileSpendLedger } from "@cubitrek/agent-passport-verifier/node"; +import { memorySpendLedger } from "@cubitrek/agent-passport"; +import { fileSpendLedger } from "@cubitrek/agent-passport/node"; const ledger = fileSpendLedger(".agent-passport/spend.jsonl"); const decision = await decide(mine, request, { ledger, engagementId: "inv-2291" }); @@ -148,7 +149,7 @@ await verifyAgentPassport({ domain: "acme.example", revocationFailure: "error" } A passport says what a company authorised. It does not say who is contacting you, because anyone can quote a public file. When the issuer publishes request keys in its passport, verify the request itself: ```typescript -import { signAgentRequest, verifyAgentCaller } from "@cubitrek/agent-passport-verifier"; +import { signAgentRequest, verifyAgentCaller } from "@cubitrek/agent-passport"; // The agent, calling out: const headers = await signAgentRequest({ method: "POST", url, body }, { keyId, privateKey }); @@ -162,7 +163,7 @@ The signature is a standard HTTP Message Signature (RFC 9421) covering the metho ## Issue a passport from code ```typescript -import { draftAgentPassport, signAgentPassport, dnsTxtRecord } from "@cubitrek/agent-passport-verifier"; +import { draftAgentPassport, signAgentPassport, dnsTxtRecord } from "@cubitrek/agent-passport"; const draft = draftAgentPassport({ domain: "acme.example", @@ -183,7 +184,7 @@ const record = dnsTxtRecord({ keyId: draft.signature.keyId, publicKeyRaw }); // ## Health-check a published passport ```typescript -import { diagnoseAgentPassport } from "@cubitrek/agent-passport-verifier"; +import { diagnoseAgentPassport } from "@cubitrek/agent-passport"; const report = await diagnoseAgentPassport({ domain: "acme.example", warnDays: 14 }); for (const check of report.checks) { @@ -196,7 +197,7 @@ Checks: delivery without redirects, content type, CORS, cache lifetimes, schema, ## MCP server ```bash -claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport-verifier agent-passport mcp +claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport agent-passport mcp ``` | Tool | What it does | @@ -222,7 +223,7 @@ Speaks MCP protocol versions 2024-11-05 through 2025-11-25 over stdio, with no d | `checkAndHold(decision, finalRequest, options?)` | The same gate in two steps: `{ ok: true, hold }` or `{ ok: false, reasons, receipt }` | | `checkExecution(decision, finalRequest, options?)` | The binding check alone: `{ ok: true }` or `{ ok: false, errors }` | | `memorySpendLedger()` | A `SpendLedger` for one process; a fleet needs a shared store with an atomic reserve | -| `fileSpendLedger(path)` | An append-only `SpendLedger` on disk. From `@cubitrek/agent-passport-verifier/node`, because it needs `node:fs` | +| `fileSpendLedger(path)` | An append-only `SpendLedger` on disk. From `@cubitrek/agent-passport/node`, because it needs `node:fs` | | `fileReceiptSink(path)`, `readReceipts(path)` | The receipt trail on disk, also from the `/node` subpath | | `toolRequest(call, rules)`, `ruleFor`, `describeRule` | Turning a tool call into a request the decision engine can answer | | `buildReceipt`, `signReceipt`, `verifyReceipt` | Receipts, and their Ed25519 signatures | diff --git a/packages/verifier/bin/agent-passport.mjs b/packages/verifier/bin/agent-passport.mjs index e826495..e9de4c9 100755 --- a/packages/verifier/bin/agent-passport.mjs +++ b/packages/verifier/bin/agent-passport.mjs @@ -155,8 +155,9 @@ next decision more cautious, while not counting spend that did raises the cap.`, guard: `agent-passport guard --policy --ledger [--receipts ] [--engagement ] -- -Runs an MCP server behind your policy. Every tools/call is decided first, and a -call the policy refuses is never sent on, so the agent cannot go around it. +Passport Guard. Runs an MCP server behind your policy. Every tools/call is +decided first, and a call the policy refuses is never sent on, so the agent +cannot go around it. claude mcp add stripe -- agent-passport guard \\ --policy ~/.agent-passport/treasury.json --ledger ~/.agent-passport/spend.jsonl \\ @@ -272,7 +273,7 @@ happened, which tool, how much, and why.`, mcp: `agent-passport mcp [--policy ] [--ledger ] Speaks MCP over stdio. To add it to Claude Code: - claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport-verifier agent-passport mcp + claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport agent-passport mcp With --policy, every authorize_agent_action call is decided against that policy as well as against any counterparty passport, and the tighter of the two binds. diff --git a/packages/verifier/bin/mcp.mjs b/packages/verifier/bin/mcp.mjs index 2b4253e..927c1a5 100644 --- a/packages/verifier/bin/mcp.mjs +++ b/packages/verifier/bin/mcp.mjs @@ -2,7 +2,7 @@ * Agent Passport MCP server over stdio. Zero dependencies: newline-delimited * JSON-RPC 2.0 per the MCP stdio transport, exposing four tools. * - * claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport-verifier agent-passport mcp + * claude mcp add agent-passport -- npx -y -p @cubitrek/agent-passport agent-passport mcp */ import { createInterface } from "node:readline"; diff --git a/packages/verifier/package-lock.json b/packages/verifier/package-lock.json index 30cd3c2..1eace47 100644 --- a/packages/verifier/package-lock.json +++ b/packages/verifier/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@cubitrek/agent-passport-verifier", + "name": "@cubitrek/agent-passport", "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cubitrek/agent-passport-verifier", + "name": "@cubitrek/agent-passport", "version": "0.1.2", "license": "MIT", "dependencies": { diff --git a/packages/verifier/package.json b/packages/verifier/package.json index a951100..96ab489 100644 --- a/packages/verifier/package.json +++ b/packages/verifier/package.json @@ -1,5 +1,5 @@ { - "name": "@cubitrek/agent-passport-verifier", + "name": "@cubitrek/agent-passport", "version": "0.1.2", "description": "Reference verifier and issuer toolkit for the Agent Passport spec, v0.1. Validates schema, binds the Ed25519 signing key to the issuer's DNS zone, verifies the canonical-JSON signature, and checks expiry and revocation. Includes an agent-passport CLI for keygen, signing and verification.", "license": "MIT", @@ -22,7 +22,14 @@ "trust", "ed25519", "well-known", - "agentic" + "agentic", + "passport-guard", + "guardrails", + "spend-limits", + "human-in-the-loop", + "mcp-proxy", + "claude-code-hook", + "receipts" ], "type": "module", "main": "./dist/index.js", diff --git a/packages/verifier/src/index.ts b/packages/verifier/src/index.ts index f13e7f8..8b3f654 100644 --- a/packages/verifier/src/index.ts +++ b/packages/verifier/src/index.ts @@ -1,9 +1,9 @@ /** - * @cubitrek/agent-passport-verifier + * @cubitrek/agent-passport * * Reference verifier for the Agent Passport spec, v0.1. * - * import { verifyAgentPassport, validate } from "@cubitrek/agent-passport-verifier"; + * import { verifyAgentPassport, validate } from "@cubitrek/agent-passport"; * * const result = await verifyAgentPassport({ * domain: "acme.example", diff --git a/packages/verifier/src/ledger-node.ts b/packages/verifier/src/ledger-node.ts index ca6d168..600d7be 100644 --- a/packages/verifier/src/ledger-node.ts +++ b/packages/verifier/src/ledger-node.ts @@ -5,7 +5,7 @@ * of the package deliberately runs unchanged in Workers and browsers. Reach * it through the subpath: * - * import { fileSpendLedger } from "@cubitrek/agent-passport-verifier/node"; + * import { fileSpendLedger } from "@cubitrek/agent-passport/node"; */ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; diff --git a/packages/verifier/src/ledger.ts b/packages/verifier/src/ledger.ts index 540c554..e5451d9 100644 --- a/packages/verifier/src/ledger.ts +++ b/packages/verifier/src/ledger.ts @@ -15,7 +15,7 @@ * * This module stays free of Node built-ins so the package keeps running in * Workers and browsers. The on-disk ledger lives in ./ledger-node.ts, behind - * the "@cubitrek/agent-passport-verifier/node" subpath. + * the "@cubitrek/agent-passport/node" subpath. */ import type { AuthorityCeiling, SpendWindow } from "./authority.js"; diff --git a/packages/verifier/src/node.ts b/packages/verifier/src/node.ts index ea84d06..c70c1d6 100644 --- a/packages/verifier/src/node.ts +++ b/packages/verifier/src/node.ts @@ -3,7 +3,7 @@ * * The main entry deliberately touches no Node built-in, so the library keeps * running in Workers and browsers. Anything that reaches the file system lives - * here instead, behind the "@cubitrek/agent-passport-verifier/node" subpath. + * here instead, behind the "@cubitrek/agent-passport/node" subpath. */ import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; diff --git a/spec/agent-passport-v0.1.md b/spec/agent-passport-v0.1.md index 5031911..9ca1be6 100644 --- a/spec/agent-passport-v0.1.md +++ b/spec/agent-passport-v0.1.md @@ -1,6 +1,6 @@ # Agent Passport, v0.1 -> A standard for verifiable, business-issued identity and authority for AI agents that talk to other AI agents across organisational boundaries. +> A standard for the signed statement a business publishes about what its AI agents are authorised to commit to when they deal with other organisations. **Status:** Draft v0.1 **Date:** 2026-04-28 @@ -25,6 +25,8 @@ The Model Context Protocol (MCP) standardised agent-to-tool calls. The Agent2Age **Agent Passport** fills that gap. It is a JSON document a business publishes at a well-known URL on its own domain, signed with a key whose public half is anchored in DNS, that declares which agents represent the business, what those agents are authorised to do on its behalf, and how a counterparty can verify and audit them. +It is a statement of authority, not of identity. Who is calling is established separately, for example with HTTP Message Signatures as profiled by the IETF Web Bot Auth working group, and the passport is then read for what that caller's business has committed to (§7, caller binding). + ## 2. Design goals 1. **Plain JSON.** No new transport, no new wire format. Any HTTP client and any JSON parser can read a passport. @@ -235,7 +237,7 @@ A v0.1-conformant **library** must expose at minimum: - `validate(passportJson) -> { ok, errors }`: schema-only. - `verify({ domain | passportJson, resolveSignerPublicKey }) -> { ok, errors, passport }`: end-to-end. -The reference implementation is [`@cubitrek/agent-passport-verifier`](../packages/verifier). +The reference implementation is [`@cubitrek/agent-passport`](../packages/verifier). ## 10. Open questions for v0.2 diff --git a/spec/proposals/local-policy.md b/spec/proposals/local-policy.md index bca8748..3e7315b 100644 --- a/spec/proposals/local-policy.md +++ b/spec/proposals/local-policy.md @@ -1,6 +1,6 @@ # Proposal: local policy, counted ceilings, and receipts -Status: implemented in `@cubitrek/agent-passport-verifier` 0.1.2. Not part of +Status: implemented in `@cubitrek/agent-passport` 0.1.2. Not part of the v0.1 passport format. Nothing here changes what a passport says or how it is verified. @@ -102,7 +102,7 @@ The in-process ledger and nonce store are for one process. A fleet needs a shared store whose reserve step is a single atomic operation, such as a Redis transaction or a database row lock. The interfaces are small on purpose. -The on-disk ledger lives behind the `@cubitrek/agent-passport-verifier/node` +The on-disk ledger lives behind the `@cubitrek/agent-passport/node` subpath rather than the main entry, because it needs `node:fs` and the rest of the package deliberately runs unchanged in Workers and browsers.