From ba56a71173f52e530c4d1b5ea2a525ea0025db19 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 18:18:43 -0700 Subject: [PATCH 1/6] =?UTF-8?q?docs(#17):=20spec=20=E2=80=94=20gate-my-too?= =?UTF-8?q?l=20skill=20+=20gateTool()=20facade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design settled in brainstorming: Mode-B page-less gateTool() facade (core of #23), placeholder age.over(21) policy + TODO, a minimal release-records sample server, load-bearing bypass test, writing-skills TDD. Mandate exposure and the AP2 mandate-chain DX are explicitly out of scope (separate design). Refs #17 #23 Signed-off-by: Diego Zuluaga --- .../2026-07-20-gate-my-tool-skill-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-gate-my-tool-skill-design.md diff --git a/docs/superpowers/specs/2026-07-20-gate-my-tool-skill-design.md b/docs/superpowers/specs/2026-07-20-gate-my-tool-skill-design.md new file mode 100644 index 0000000..2b7be14 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-gate-my-tool-skill-design.md @@ -0,0 +1,126 @@ +# gate-my-tool skill + `gateTool()` facade — design + +**Issue:** #17 (folds in the core of #23). **Branch:** `feat/17-gate-my-tool` (off `origin/main`). +**Date:** 2026-07-20. + +## Goal + +A committed repo skill, `gate-my-tool`, that lets a coding agent install a CredentAgent +consent gate onto an existing MCP tool **in one shot**: *"gate my `release-records` tool"* → +the tool is wrapped so it refuses-until-proven, plus a load-bearing bypass test. To install +**one honest call** (not ~60 lines of hand-rolled plumbing), the skill relies on a new +library facade, `gateTool()` — the general Mode-B "gate a tool" helper (the core of #23). + +## Context — three enforcement surfaces, one policy language + +The library gates consequential actions in three places. This work builds the **second**: + +| Surface | Scenario | Shape | Returns | +| --- | --- | --- | --- | +| `requirements()` + `mount()` | HP · hosted page | resolver | a `requires` manifest; a human completes it on your page | +| **`gateTool()` (NEW)** | **HP · page-less tool** | **wrapper** | **a gated handler; an agent drives the refuse→prove→re-call loop** | +| `DelegatedGate.preApprove()`/`spend()` | HNP · delegated | stateful object | a bounded grant; agents spend later, no human | + +All three enforce the **same policy language** (`age.over(21)`, `payment.in("usd")`, +`defineCredential`). This spec touches only the middle row. The HNP row and the +**mandate-chain DX** (exposing Intent/Cart/Payment mandates) are out of scope here — that +is a separate design (coordinate with the HNP session; do not redesign `DelegatedGate`). + +## 1. `gateTool()` — the facade (a `CredentAgent` method) + +Written caller-first (the DX test). A configured `CredentAgent` already holds the store + +walletOrigin, so wrapping a tool is **one declarative call**: + +```js +const credentagent = new CredentAgent({ walletOrigin }); + +server.registerTool("release-records", schema, credentagent.gateTool( + async ({ subject }) => ({ structuredContent: releaseRecords({ subject }) }), + { + require: [ required(age.over(21)) ], // same policy shape as requirements() + order: (args) => ({ id: args.subject, total: 0, currency: "USD" }), + }, +)); +``` + +Behavior: +- When the order is **not yet proven** (per `credentagent.store`, keyed by order id), the + wrapped handler returns a `verification_required` envelope with an **action-agnostic** + instruction (NOT the checkout-worded `envelopeInstruction()` — that is #23's second half; + derive a neutral instruction from the envelope fields). +- When **proven**, it calls the real handler. +- Built on `requirements()` internally (resolve policy → check store → block-or-run). Same + `order` + policy nouns → consistent with `requirements()`. +- Enforce-by-construction: the wrap is the enforcement point, so a tool cannot fail open by + "forgetting the check." + +**Consistency / naming:** method on `CredentAgent` (like `requirements`/`mount`). Name is +`gateTool` — `gate` is taken by the effect builder in `credentials.ts`. + +**Honesty:** the envelope keeps `trust_level: "presence-only-demo"`. `gateTool` does not +imply issuer-verified trust. The `approve_url` points at where `mount()` serves the proving +page; if the host has not mounted one, that is the dev's to wire (state it, don't fake it). + +**Collision note:** as a `CredentAgent` method this edits `client.ts`, which PR #84 also +edits (additively, different region) — trivial rebase, no logic overlap. + +## 2. The skill — `.claude/skills/gate-my-tool/SKILL.md` + +Unprefixed (matches `add-ceremony-rail`, `write-bypass-test`, `publish-release`). When an +agent is told *"gate my `` tool"*, it: +1. Locates the named tool's `registerTool` handler. +2. Wraps it in `credentagent.gateTool(handler, { require: [ required(age.over(21)) ] /* TODO: swap credential */, order })`. + The policy is a **placeholder** with a clear TODO — the dev picks the real credential. +3. Adds the **load-bearing bypass test** (follow the `write-bypass-test` skill): an ungated + call returns the envelope (`isVerificationRequired` true), NOT the action result; assert + the typed refusal precisely; the test must go **red** if the wrap is removed. + +The skill authoring itself follows the `writing-skills` skill (TDD for skills, below). + +## 3. The sample — `examples/gate-my-tool-sample/` + +A tiny runnable MCP server with **one ungated** `registerTool` action: `release-records` +(a non-commerce disclosure action — keeps the identity-first story, not just checkout): + +```js +function releaseRecords({ subject }) { + return { released: true, subject, records: [`record:${subject}:summary`] }; +} +server.registerTool("release-records", { subject: z.string() }, + async ({ subject }) => ({ + structuredContent: releaseRecords({ subject }), + content: [{ type: "text", text: `Released records for ${subject}.` }], + }), +); +``` + +It is both the skill's verification target and the before/after demo: +*ungated tool → run the skill → gated + passing bypass test.* + +## 4. Testing (two layers) + +- **`gateTool()`** (vitest, gate package): proven → runs the handler; unproven → returns the + envelope. Plus a **bypass test** that fails if the proven-check is deleted (write-bypass-test + discipline — prove it's load-bearing, say which line you deleted). +- **The skill** (writing-skills TDD): a **RED baseline** — dispatch a subagent WITHOUT the + skill, told "gate the release-records tool," and document how it goes wrong (hides a button, + gates only in prose, skips the bypass test, hand-rolls the envelope). Then **GREEN** with the + skill: the sample tool ends gated + a passing, load-bearing bypass test. Close loopholes. + +## 5. Scope / YAGNI — explicitly OUT + +- Mode A (`requirements`/`mount` + page) wiring; credential inference (placeholder only); + whole-server sweeps (one named tool); framework auto-detection. +- The discovery surface / `agents.md` (#20) — **and note in the PR that #17's premise that a + discovery surface "ships in v0.1" is stale; it does not exist in the repo.** +- **Mandate exposure / the AP2 mandate-chain DX** — a separate design; keep `gateTool` + minimal (mandates hidden) so that work extends it additively. + +## 6. Deliverables + +1. `packages/credentagent-gate/src/` — `gateTool()` (method on `CredentAgent`; action-agnostic + instruction helper) + unit + bypass tests. Export nothing new that leaks internals. +2. `.claude/skills/gate-my-tool/SKILL.md` + the writing-skills baseline evidence. +3. `examples/gate-my-tool-sample/` — the runnable ungated sample. +4. README: the three-surface table + the `gateTool` snippet as the canonical explainer. +5. DCO-signed commits; PR against `main` (independent of #84). From 252af897bf05fd4f545513fd11054928ef580e70 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 18:54:41 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(gate,#17):=20credentagent.gate()=20?= =?UTF-8?q?=E2=80=94=20the=20Mode-B=20gate-a-tool=20facade=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap any MCP tool handler in one call: unproven -> the typed verification_required envelope (approve link + action-agnostic agent instruction, isError unset); proven -> the handler runs unchanged. Built on the requirements() resolver (enforcedAt: "tool"), proof keyed per provenBy subject on the client's store. DX (cold-reader validated over 3 rounds; spec's shape revised on maintainer feedback): gate(handler, { require, provenBy, name? }) — no required() wrapper noise, no order/total/currency on identity actions. Fail-fast at wrap time on payment/discount steps (no silent no-op); fail-closed on an empty provenBy (no shared proof bucket); warns once when no ceremony is mounted in-process. Additive envelope changes: custom credential ids in present.credential, resumePoll override. Bypass tests proven load-bearing by mutation (write-bypass-test): (A) deleting the proven-check entries.find in gate.ts -> 6/9 red; (B) ageVerified === true weakened to record != null -> negative-claim red; (C) provenBy(args) replaced with a shared constant -> cross-subject red. Signed-off-by: Diego Zuluaga Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4 Signed-off-by: Diego Zuluaga --- packages/credentagent-gate/src/client.ts | 35 ++++ packages/credentagent-gate/src/envelope.ts | 9 +- packages/credentagent-gate/src/gate.test.ts | 188 ++++++++++++++++++++ packages/credentagent-gate/src/gate.ts | 135 ++++++++++++++ packages/credentagent-gate/src/gated.ts | 6 +- packages/credentagent-gate/src/index.ts | 4 + 6 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 packages/credentagent-gate/src/gate.test.ts create mode 100644 packages/credentagent-gate/src/gate.ts diff --git a/packages/credentagent-gate/src/client.ts b/packages/credentagent-gate/src/client.ts index 6cbf968..9488ac9 100644 --- a/packages/credentagent-gate/src/client.ts +++ b/packages/credentagent-gate/src/client.ts @@ -7,6 +7,8 @@ import type { Credential, CredentAgentOptions, GateOrder, ReaderIdentity, Step, import { resolveRequirements } from "./manifest.js"; import { MemoryVerificationStore } from "./store.js"; import { mountCeremony, type CeremonyApp, type CeremonySeams } from "./ceremony/mount.js"; +import { makeToolGate, type GateOptions } from "./gate.js"; +import type { MinimalToolResult } from "./gated.js"; x509.cryptoProvider.set(globalThis.crypto); @@ -92,6 +94,39 @@ export class CredentAgent { return resolveRequirements(order, policy, { walletOrigin: this.walletOrigin, mountedRoutes: this.mountedRoutes }); } + /** + * Gate an MCP tool handler: the wrapped tool refuses-until-proven. An unproven + * call returns the typed `verification_required` envelope (approve link + agent + * instruction) instead of running; once the credential is proven for + * `provenBy(args)` (per this client's per-subject store — invariant 4), the real + * handler runs. Same policy nouns as `requirements()`; the wrap is the + * enforcement point, so the tool cannot fail open by forgetting the check. + * + * server.registerTool("release-records", config, credentagent.gate(handler, { + * require: age.over(21), + * provenBy: ({ subject }) => subject, + * })); + */ + gate( + handler: (args: A, ...rest: unknown[]) => R | Promise, + opts: GateOptions, + ): (args: A, ...rest: unknown[]) => Promise { + return makeToolGate(handler, opts, { + store: this.store, + resolve: (order, steps) => { + // Register-on-resolve, like requirements(): the mounted rails + + // completeOrder can reach each credential's request/verify by id. + for (const s of steps) this.registry.set(s.credential.id, s.credential); + return resolveRequirements(order, steps, { + walletOrigin: this.walletOrigin, + mountedRoutes: this.mountedRoutes, + enforcedAt: "tool", + }); + }, + isMounted: () => this.mountedRoutes, + }); + } + /** * Context 2 — wire the verification ceremony onto your Express app. * diff --git a/packages/credentagent-gate/src/envelope.ts b/packages/credentagent-gate/src/envelope.ts index dce920b..6fb6f45 100644 --- a/packages/credentagent-gate/src/envelope.ts +++ b/packages/credentagent-gate/src/envelope.ts @@ -60,7 +60,8 @@ export interface VerificationRequired { order: { id: string; total: number; currency: string }; reason: { gate: string; pass: false; detail: string }; present: { - credential: BuiltinKind; + /** A built-in kind, or a custom `defineCredential` id (same wire shape: a string). */ + credential: BuiltinKind | (string & {}); /** Age threshold, when the credential is `age`. */ min_age?: number; /** The DCQL the wallet will receive. */ @@ -75,13 +76,15 @@ export interface VerificationRequired { export interface BuildEnvelopeArgs { order: { id: string; total: number; currency: string }; - credential: BuiltinKind; + credential: BuiltinKind | (string & {}); request: DcqlQuery; approveUrl: string; detail: string; minAge?: number; gate?: string; resumeTool?: string; + /** Override the checkout-worded default poll hint (e.g. a gated tool's "re-call…"). */ + resumePoll?: string; trustLevel?: TrustLevel; } @@ -102,7 +105,7 @@ export function buildVerificationRequired(args: BuildEnvelopeArgs): Verification request: args.request, approve_url: args.approveUrl, }, - resume: { tool: args.resumeTool ?? "get-order-status", poll: "until status=completed or refused" }, + resume: { tool: args.resumeTool ?? "get-order-status", poll: args.resumePoll ?? "until status=completed or refused" }, trust_level: args.trustLevel ?? "presence-only-demo", }; } diff --git a/packages/credentagent-gate/src/gate.test.ts b/packages/credentagent-gate/src/gate.test.ts new file mode 100644 index 0000000..38f0a36 --- /dev/null +++ b/packages/credentagent-gate/src/gate.test.ts @@ -0,0 +1,188 @@ +// credentagent.gate() — the Mode-B "gate a tool" facade (spec 2026-07-20, #17/#23 core). +// The wrap IS the enforcement point: an unproven call returns the typed +// verification_required envelope and the real handler NEVER runs (invariant 1 — +// enforce server-side on every completion path; a page-less tool's completion +// path is its handler). Proof is keyed per subject (invariant 4) and must be the +// explicit positive claim (invariant 5). + +import { describe, it, expect, vi } from "vitest"; +import { CredentAgent } from "./client.js"; +import { age, defineCredential, dcql, gate, membership, payment } from "./credentials.js"; +import { isVerificationRequired } from "./envelope.js"; + +function releaseRecords(subject: string) { + return { released: true, subject, records: [`record:${subject}:summary`] }; +} + +/** A gated release-records tool + a `ran` probe, on a fresh CredentAgent. */ +function gatedFixture(opts?: { require?: Parameters[1]["require"] }) { + const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + const calls: string[] = []; + const gated = credentagent.gate( + async ({ subject }: { subject: string }) => { + calls.push(subject); + return { content: [{ type: "text" as const, text: JSON.stringify(releaseRecords(subject)) }] }; + }, + { + require: opts?.require ?? age.over(21), + provenBy: ({ subject }: { subject: string }) => subject, + name: "release-records", + }, + ); + const call = async (subject: string) => + (await gated({ subject })) as { + structuredContent?: Record; + content?: { type: string; text: string }[]; + }; + return { credentagent, gated, call, calls }; +} + +describe("CredentAgent.gate", () => { + it("REFUSES an unproven gated action — returns the verification_required envelope, the handler never runs", async () => { + const { call, calls } = gatedFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = await call("casey"); + warn.mockRestore(); + + // The attack: call the tool with no verification on file. The action must not run. + expect(calls).toEqual([]); + const env = res.structuredContent; + expect(isVerificationRequired(env)).toBe(true); + if (!isVerificationRequired(env)) throw new Error("unreachable"); + // Assert the typed refusal precisely — not just "didn't succeed". + expect(env.reason.pass).toBe(false); + expect(env.order.id).toBe("casey"); + expect(env.present.credential).toBe("age"); + expect(env.present.min_age).toBe(21); + expect(env.present.approve_url).toContain("https://records.example"); + expect(env.present.approve_url).toContain("casey"); + expect(env.resume.tool).toBe("release-records"); + expect(env.resume.poll).toBe("re-call with the same arguments until verification_required clears"); + expect(env.trust_level).toBe("presence-only-demo"); + // The agent-facing instruction rides in content and is ACTION-agnostic — + // never the checkout wording ("order is placed", "buyer"). + const text = res.content?.[0]?.text ?? ""; + expect(text).toContain(env.present.approve_url); + expect(text).toContain("release-records"); + expect(text).not.toMatch(/order is placed|buyer/i); + }); + + it("runs the handler once proof is recorded under the envelope's own id — and REFUSES a cross-subject bleed (casey's proof never unlocks riley)", async () => { + const { credentagent, call, calls } = gatedFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // The loop, end to end at unit scope: the refusal names the id the ceremony + // will record the proof under (order.id) — write the proof exactly there, + // as the credential rail's verify handler would. + const refusal = await call("casey"); + const env = refusal.structuredContent as { order: { id: string } }; + await credentagent.store.write(env.order.id, { ageVerified: true }); + + const res = await call("casey"); + expect(calls).toEqual(["casey"]); + expect(res.content?.[0]?.text).toContain('"released":true'); + + // The attack: a DIFFERENT subject rides on casey's proof (invariant 4). + const riley = await call("riley"); + expect(calls).toEqual(["casey"]); // handler did not run again + expect(isVerificationRequired(riley.structuredContent)).toBe(true); + warn.mockRestore(); + }); + + it("REFUSES a negative/absent claim: a record WITHOUT ageVerified === true is not proof (invariant 5)", async () => { + const { credentagent, call, calls } = gatedFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // A record EXISTS for casey (e.g. loyalty ran), but the age claim is false. + await credentagent.store.write("casey", { ageVerified: false, loyalty: { applied: true, membershipNumber: "M-1" } }); + const res = await call("casey"); + warn.mockRestore(); + expect(calls).toEqual([]); + expect(isVerificationRequired(res.structuredContent)).toBe(true); + }); + + it("gates on a CUSTOM defineCredential — proven only via verifiedGates[its id], never another id's proof", async () => { + const license = defineCredential({ + id: "license", + request: dcql({ docType: "org.example.license.1", claims: ["license_number"] }), + verify: (claims) => typeof claims.license_number === "string", + effect: gate(), + ui: { label: "Professional license", action: "Present your license" }, + }); + const { credentagent, call, calls } = gatedFixture({ require: license }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const refusal = await call("casey"); + const env = refusal.structuredContent; + expect(isVerificationRequired(env)).toBe(true); + if (!isVerificationRequired(env)) throw new Error("unreachable"); + expect(env.present.credential).toBe("license"); + + // The attack: a proof for a DIFFERENT credential id must not satisfy this gate. + await credentagent.store.write("casey", { verifiedGates: { "other-cred": true } }); + expect(isVerificationRequired((await call("casey")).structuredContent)).toBe(true); + expect(calls).toEqual([]); + + await credentagent.store.write("casey", { verifiedGates: { license: true } }); + await call("casey"); + expect(calls).toEqual(["casey"]); + warn.mockRestore(); + }); + + it("skips a gate whose .when() predicate says it does not apply", async () => { + const { call, calls } = gatedFixture({ + require: age.over(21).when((order) => order.id.startsWith("restricted:")), + }); + await call("casey"); // predicate false — the gate is not in the manifest + expect(calls).toEqual(["casey"]); + }); + + it("fails FAST at wrap time on a policy gate() cannot honor (payment / discount — no silent no-op)", () => { + const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + const handler = async () => ({ content: [] }); + expect(() => + credentagent.gate(handler, { require: payment.in("usd"), provenBy: () => "s" }), + ).toThrow(/payment/i); + expect(() => + credentagent.gate(handler, { require: membership.discount(10), provenBy: () => "s" }), + ).toThrow(/discount/i); + expect(() => credentagent.gate(handler, { require: [], provenBy: () => "s" })).toThrow(/require/); + }); + + it("fails CLOSED on an empty subject — refuses loudly rather than sharing a proof bucket", async () => { + const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + let ran = false; + const gated = credentagent.gate( + async () => { + ran = true; + return { content: [] }; + }, + { require: age.over(21), provenBy: () => "" }, + ); + await expect(gated({})).rejects.toThrow(/provenBy/); + expect(ran).toBe(false); + }); + + it("defaults resume.tool to \"this-tool\" and the instruction to \"this tool\" when no name is given", async () => { + const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + const gated = credentagent.gate(async () => ({ content: [] }), { + require: age.over(21), + provenBy: () => "casey", + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = (await gated({})) as { structuredContent?: Record; content?: { text: string }[] }; + warn.mockRestore(); + const env = res.structuredContent; + if (!isVerificationRequired(env)) throw new Error("expected envelope"); + expect(env.resume.tool).toBe("this-tool"); + expect(res.content?.[0]?.text).toContain("this tool"); + }); + + it("warns ONCE at refusal time when the ceremony is not mounted in this process (the approve link may be a dead end)", async () => { + const { call } = gatedFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await call("casey"); + await call("casey"); + expect(warn.mock.calls.filter(([m]) => String(m).includes("mount")).length).toBe(1); + warn.mockRestore(); + }); +}); diff --git a/packages/credentagent-gate/src/gate.ts b/packages/credentagent-gate/src/gate.ts new file mode 100644 index 0000000..978ae22 --- /dev/null +++ b/packages/credentagent-gate/src/gate.ts @@ -0,0 +1,135 @@ +// credentagent.gate() — wrap an MCP tool handler so it refuses-until-proven. +// +// The general Mode-B "gate a tool" facade (#17/#23): a page-less tool returns the +// typed `verification_required` envelope instead of running, and an agent drives +// the loop — show the person the approve link, they prove the credential on their +// phone, the agent re-calls and the handler runs. The wrap IS the enforcement +// point (enforce-by-construction): a wrapped tool cannot fail open by +// "forgetting the check" (Security invariant 1). +// +// Built on the same resolver as `requirements()` — one policy language, one +// code→data boundary — with `enforcedAt: "tool"` stated honestly in each entry. + +import type { Credential, GateOrder, Step, VerificationManifestEntry, VerificationRecord, VerificationStore } from "./types.js"; +import type { MinimalToolResult } from "./gated.js"; +import { buildVerificationRequired, type VerificationRequired } from "./envelope.js"; + +/** Options for `credentagent.gate()` — what to prove, and whose proof counts. */ +export interface GateOptions { + /** The credential(s) to prove: `age.over(21)`, a `defineCredential(...)`, or an array. + * Only `gate()`-effect credentials belong here — payment settles on the checkout + * ceremony (`requirements()` + `mount()`), and a discount is a benefit, not a gate. */ + require: Credential | Credential[]; + /** + * Whose proof unlocks the call — derive a stable id (a user / session / subject id) + * from the tool args. Calls that derive the same value share one proof; proofs are + * stored per-subject on this server's store, NEVER process-global (Security + * invariant 4). Returning a shared constant would let one person's proof unlock + * everyone's calls — an empty/missing value is refused fail-closed, not shared. + */ + provenBy: (args: A) => string; + /** Your registered tool name — names the re-call in the refusal (`resume.tool`). + * Optional; without it the refusal says "this tool" (the agent knows what it called). */ + name?: string; +} + +/** The seams `CredentAgent.gate()` binds: its store + its policy resolver. */ +export interface GateSeams { + store: VerificationStore; + resolve: (order: GateOrder, steps: Step[]) => VerificationManifestEntry[]; + /** True once `mount()` wired the ceremony rails in this process (warn honestly if not). */ + isMounted: () => boolean; +} + +/** True iff THIS order's record proves the entry (explicit positive — invariant 5). */ +function proven(entry: VerificationManifestEntry, record: VerificationRecord | undefined): boolean { + if (entry.credential === "age") return record?.ageVerified === true; + return record?.verifiedGates?.[entry.credential] === true; +} + +/** Action-agnostic agent instruction — NOT the checkout-worded `envelopeInstruction()`. */ +export function toolEnvelopeInstruction(env: VerificationRequired, toolName?: string): string { + const recall = toolName ? `\`${toolName}\`` : "this tool"; + return ( + `This action requires ${env.reason.gate} before it can run. Ask the person to open this ` + + `link on their phone and present the credential: ${env.present.approve_url} — then call ` + + `${recall} again with the same arguments. Do not treat the action as done until it ` + + `returns a result instead of verification_required.` + ); +} + +export function makeToolGate( + handler: (args: A, ...rest: unknown[]) => R | Promise, + opts: GateOptions, + seams: GateSeams, +): (args: A, ...rest: unknown[]) => Promise { + const credentials = Array.isArray(opts.require) ? opts.require : [opts.require]; + // Fail fast at WRAP time on a policy this gate cannot honor — a credential that + // looks enforced but is a silent no-op would be a foot-gun for a consent library. + if (credentials.length === 0) { + throw new Error(`gate(): \`require\` is empty — pass the credential(s) to prove (e.g. age.over(21)).`); + } + for (const c of credentials) { + if (c.effect.kind === "authorize") { + throw new Error( + `gate(${c.id}): payment authorization settles on the checkout ceremony — use ` + + `credentagent.requirements() + credentagent.mount() for payment. gate() proves identity credentials.`, + ); + } + if (c.effect.kind === "discount") { + throw new Error( + `gate(${c.id}): a discount is a benefit applied at checkout, not a blocking gate — ` + + `it has no meaning on a gated tool. Use a gate()-effect credential here.`, + ); + } + } + const steps: Step[] = credentials.map((credential) => ({ credential, required: true })); + let warnedUnmounted = false; + + return async (args: A, ...rest: unknown[]): Promise => { + const subject = opts.provenBy(args); + if (typeof subject !== "string" || subject.trim() === "") { + // Fail CLOSED: a missing subject must never collapse callers into a shared + // proof bucket (cross-user bleed, invariant 4) — refuse loudly instead. + throw new Error( + `gate(): \`provenBy\` returned ${JSON.stringify(subject)} — it must derive a non-empty ` + + `per-caller id from the tool args, or one person's proof would unlock everyone's calls.`, + ); + } + // A $0 ACTION, not a sale — the gate doesn't care that there's no money. + const order: GateOrder = { id: subject, total: 0, currency: "USD", lines: [] }; + const entries = seams.resolve(order, steps); + const record = await seams.store.read(subject); + const unproven = entries.find((e) => !proven(e, record)); + if (!unproven) return handler(args, ...rest); + + if (!seams.isMounted() && !warnedUnmounted) { + warnedUnmounted = true; + console.warn( + `[credentagent] gate(): approve link points at ${unproven.approveUrl}, but ` + + `credentagent.mount(app) has not run in this process — if no server serves that route, ` + + `the link is a dead end. Mount the ceremony on your web app (sharing this store) to serve it.`, + ); + } + + const step = steps.find((s) => s.credential.id === unproven.credential); + const env = buildVerificationRequired({ + order, + credential: unproven.credential, + request: step!.credential.request, + approveUrl: unproven.approveUrl ?? "", + detail: `${unproven.label} must be proven before this action can run — no proof on file for "${subject}".`, + minAge: unproven.minAge, + gate: unproven.label, + resumeTool: opts.name ?? "this-tool", + resumePoll: "re-call with the same arguments until verification_required clears", + }); + return { + // VerificationRequired is a plain JSON object; widen to the tool-result shape. + // (Do NOT declare an MCP `outputSchema` on a gated tool — the envelope must be + // free to replace the success shape in `structuredContent`.) + structuredContent: env as unknown as Record, + content: [{ type: "text", text: toolEnvelopeInstruction(env, opts.name) }], + }; + }; +} diff --git a/packages/credentagent-gate/src/gated.ts b/packages/credentagent-gate/src/gated.ts index 212c89a..9e0c5bd 100644 --- a/packages/credentagent-gate/src/gated.ts +++ b/packages/credentagent-gate/src/gated.ts @@ -38,9 +38,9 @@ export interface GateDeps { } /** - * @deprecated v0.1 uses consolidated Mode A — wrap your checkout tool with - * `CredentAgent.requirements(order, policy)` instead. `gated()` is the Mode-B - * blocking shim, kept for page-less tools / one minor version. + * @deprecated Use `credentagent.gate(handler, { require, provenBy })` — the + * general Mode-B facade — for page-less tools, or `CredentAgent.requirements()` + * for checkout. `gated()` is the v0-era shim, kept one minor version. * * Wrap an MCP tool handler so it returns a `verification_required` envelope when * the age gate isn't met, instead of completing. The handler receives the diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index d31fcb6..265ce45 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -19,6 +19,10 @@ export type { ExpressApp } from "./client.js"; // ── Policy builders + extensibility ──────────────────────────────────────── export { age, membership, payment, required, optional, defineCredential, dcql, gate, discount, authorize } from "./credentials.js"; +// ── Gate a tool (Mode B): credentagent.gate(handler, { require, subject }) ── +// The wrapper itself is a CredentAgent method; only its option type is public. +export type { GateOptions } from "./gate.js"; + // ── Store ──────────────────────────────────────────────────────────────── export { MemoryVerificationStore } from "./store.js"; From 4e8cdae08619f10027447a8eaf2e13f823d15d9e Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 18:54:53 -0700 Subject: [PATCH 3/6] feat(examples,#17): gate-my-tool-sample target + gate-any-action on the facade (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/gate-my-tool-sample/ is a tiny runnable MCP server with ONE deliberately ungated release-records tool — the gate-my-tool skill's verification target and the before/after demo (README states the intent). examples/gate-any-action.mjs now uses credentagent.gate() instead of hand-rolling the envelope with buildVerificationRequired — the plumbing the old example carried (and its 'action-agnostic instruction helper is a follow-up' note) moved into the library. Signed-off-by: Diego Zuluaga Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4 Signed-off-by: Diego Zuluaga --- examples/README.md | 40 +++++++------ examples/gate-any-action.mjs | 79 ++++++++++--------------- examples/gate-my-tool-sample/README.md | 24 ++++++++ examples/gate-my-tool-sample/server.mjs | 42 +++++++++++++ 4 files changed, 117 insertions(+), 68 deletions(-) create mode 100644 examples/gate-my-tool-sample/README.md create mode 100644 examples/gate-my-tool-sample/server.mjs diff --git a/examples/README.md b/examples/README.md index e5240f8..28ddac6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,7 +11,8 @@ Each is runnable against the two `@openmobilehub/credentagent-*` packages (build - [`run-storefront/`](run-storefront/) — run THIS repo's storefront directly (stateful + stateless side by side) **Gating patterns** (identity-first, beyond commerce) -- [`gate-any-action.mjs`](#gate-any-actionmjs--gate-a-non-commerce-action-identity-first-no-checkout) — gate a non-commerce action, no checkout +- [`gate-any-action.mjs`](#gate-any-actionmjs--gate-a-non-commerce-action-identity-first-no-checkout) — gate a non-commerce action with `credentagent.gate()`, no checkout +- [`gate-my-tool-sample/`](gate-my-tool-sample/) — the deliberately **ungated** server the `gate-my-tool` skill targets (before/after demo) **Cart Mandate / stateless** (004) - [`stateless-orders/`](stateless-orders/) — the created order rides in a signed Cart Mandate on the link @@ -224,21 +225,24 @@ payments is one application* — by gating a **non-commerce** action: an MCP too records, behind an identity credential, with **no payment anywhere**. ```ts -import { buildVerificationRequired, isVerificationRequired, ageDcql } from "@openmobilehub/credentagent-gate"; - -function releaseRecords(args, ctx) { - if (!ctx.ageVerified) { - return buildVerificationRequired({ // ← gate any tool call: return a typed refusal, - order: { id: args.requestId, total: 0, currency: "USD" }, // a $0 ACTION, not a sale - credential: "age", minAge: 21, request: ageDcql(), - approveUrl: `https://shop.example/credentagent/credential?order=${args.requestId}&cred=age`, - detail: "Releasing these records requires proof the requester is 21+.", - }); - } - return { released: true, records: [/* … */] }; -} +import { CredentAgent, age } from "@openmobilehub/credentagent-gate"; + +const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + +const releaseRecords = credentagent.gate( // ← the whole integration: wrap the handler + async ({ subject }) => ({ released: true, subject, records: [/* … */] }), + { + require: age.over(21), // the credential to prove + provenBy: ({ subject }) => subject, // whose proof unlocks the call + }, +); ``` +An unproven call returns the typed `verification_required` refusal (a **$0 action**, not a +sale) with the approve link and an action-agnostic agent instruction; a proven call runs the +handler unchanged. (This example previously hand-rolled the envelope with +`buildVerificationRequired` — `credentagent.gate()` absorbed that plumbing.) + ### Run it ```bash @@ -253,9 +257,7 @@ after the credential is proven. The same shape gates `approve-deploy`, `file-pre ### Honest limits - The envelope + the gating decision are real today. The user proves on the `approve_url` **page** that - `credentagent.mount()` serves (see `storefront.mjs` for the full ceremony); a fully **page-less** proving - handshake is on the roadmap. -- The built-in `envelopeInstruction()` is worded for the **checkout** framing ("buyer", "placed"), so this - example builds an **action-agnostic** instruction from the envelope's fields instead. (An action-agnostic - instruction helper is a small follow-up.) + `credentagent.mount()` serves (see `storefront.mjs` for the full ceremony); `gate()` **warns at refusal + time** when nothing is mounted in the process, and a standalone proving-page mount for page-less + servers is a tracked follow-up. - `trust_level` is `"presence-only-demo"` — don't gate anything needing a real safety guarantee on it yet. diff --git a/examples/gate-any-action.mjs b/examples/gate-any-action.mjs index 1025fdb..520e73e 100644 --- a/examples/gate-any-action.mjs +++ b/examples/gate-any-action.mjs @@ -7,61 +7,42 @@ // tool that releases sensitive records — behind an identity credential, with no payment // anywhere. Identity leads; commerce is just one of the actions you can gate. // -// It uses the Mode-B `verification_required` envelope: instead of performing the action, a -// gated tool returns a TYPED REFUSAL the agent drives — share a link, the user proves the -// credential on their phone, the agent re-calls and the action runs. The agent keys on the -// `_credentagent` sentinel (isVerificationRequired) and follows envelopeInstruction. +// `credentagent.gate(handler, { require, provenBy })` is the whole integration: an unproven +// call returns a TYPED REFUSAL the agent drives — share the approve link, the person proves +// the credential on their phone, the agent re-calls and the action runs. Agents detect the +// handshake with isVerificationRequired(result.structuredContent). // -// HONESTY: the envelope + the gating decision are real today. The user proves on the -// `approve_url` PAGE that `credentagent.mount()` serves (see examples/storefront.mjs for the full -// ceremony); a fully page-LESS proving handshake is on the roadmap (ROADMAP.md). trust_level -// is "presence-only-demo" — the wire crypto is real, the issuer trust anchor is not yet, so -// don't put a presence-only gate in front of anything that needs a real safety guarantee. +// HONESTY: the envelope + the gating decision are real today. The person proves on the +// approve_url PAGE the ceremony mount serves (see examples/storefront.mjs) — gate() warns +// below because nothing is mounted in this process. trust_level is "presence-only-demo": +// the wire crypto is real, the issuer trust anchor is not yet, so don't put a +// presence-only gate in front of anything that needs a real safety guarantee. -import { - buildVerificationRequired, - isVerificationRequired, - ageDcql, -} from "@openmobilehub/credentagent-gate"; +import { CredentAgent, age, isVerificationRequired } from "@openmobilehub/credentagent-gate"; -// A sensitive action an agent might be asked to perform — NOT a purchase. The gate is the -// same shape you'd put in front of "approve-deploy", "file-prescription-refill", or -// "grant-access": prove a credential first, then act. -function releaseRecords(args, ctx) { - if (!ctx.ageVerified) { - // Gate any tool call: return the typed refusal instead of doing the action. The - // "order" here is a $0 ACTION, not a sale — the gate doesn't care that there's no money. - return buildVerificationRequired({ - order: { id: args.requestId, total: 0, currency: "USD" }, - credential: "age", - minAge: 21, - request: ageDcql(), - approveUrl: `https://example.test/credentagent/credential?order=${args.requestId}&cred=age`, - gate: "Age over 21", - detail: "Releasing these records requires proof the requester is 21 or older.", - resumeTool: "get-record-status", - }); - } - return { released: true, subject: args.subject, records: [`record:${args.subject}:summary`] }; -} +const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + +// A sensitive action an agent might be asked to perform — NOT a purchase. The same wrap +// fits "approve-deploy", "file-prescription-refill", or "grant-access". +const releaseRecords = credentagent.gate( + async ({ subject }) => ({ released: true, subject, records: [`record:${subject}:summary`] }), + { + require: age.over(21), // the credential to prove — swap in any defineCredential + provenBy: ({ subject }) => subject, // whose proof unlocks the call (per subject, never global) + name: "release-records", + }, +); // 1) Ungated call — the agent receives a verification_required envelope, not the records. -const refusal = releaseRecords({ requestId: "REQ-1", subject: "patient-7" }, { ageVerified: false }); +const refusal = await releaseRecords({ subject: "patient-7" }); console.log("\n— ungated tool call —"); -console.log(" is a verification handshake:", isVerificationRequired(refusal)); -console.log(" gate:", refusal.reason.gate, "| trust_level:", refusal.trust_level); -// NOTE: the built-in envelopeInstruction() is worded for the CHECKOUT framing ("buyer", -// "placed") — fine for the storefront, but for a non-commerce action build the agent -// instruction from the envelope's fields directly (action-agnostic). An action-agnostic -// instruction helper is a small follow-up (see ROADMAP). -const instruction = - `This action is gated. ${refusal.reason.detail} ` + - `Send the requester this link to prove the credential on their phone: ${refusal.present.approve_url} — ` + - `then re-call once \`${refusal.resume.tool}\` reports completion. Don't perform the action until then.`; -console.log(" agent instruction:\n ", instruction); +console.log(" is a verification handshake:", isVerificationRequired(refusal.structuredContent)); +console.log(" gate:", refusal.structuredContent.reason.gate, "| trust_level:", refusal.structuredContent.trust_level); +console.log(" agent instruction:\n ", refusal.content[0].text); -// 2) After the user proves age on the approve_url page (which credentagent.mount() serves), the -// agent re-calls the tool and the action runs — no payment ever involved. -const ok = releaseRecords({ requestId: "REQ-1", subject: "patient-7" }, { ageVerified: true }); +// 2) The person proves age on the approve_url page (served by the ceremony mount), which +// records the proof for THIS subject — simulated here by writing the store directly. +await credentagent.store.write("patient-7", { ageVerified: true }); +const ok = await releaseRecords({ subject: "patient-7" }); console.log("\n— after the credential is proven —"); console.log(" ", JSON.stringify(ok), "\n"); diff --git a/examples/gate-my-tool-sample/README.md b/examples/gate-my-tool-sample/README.md new file mode 100644 index 0000000..469693a --- /dev/null +++ b/examples/gate-my-tool-sample/README.md @@ -0,0 +1,24 @@ +# gate-my-tool-sample — the skill's before/after target + +A tiny runnable MCP server with **one deliberately ungated tool**, `release-records`: +a consequential disclosure action (identity-first — no cart, no checkout anywhere). + +```bash +npm run build --workspaces # once, from the repo root +node examples/gate-my-tool-sample/server.mjs # serves release-records over stdio +``` + +## The demo + +1. **Before** — this server, as committed: any agent can call `release-records` + and the records come back. No consent, no proof. +2. **Run the skill** — tell your coding agent: *"gate my `release-records` tool"*. + The [`gate-my-tool`](../../.claude/skills/gate-my-tool/SKILL.md) skill wraps the + handler in `credentagent.gate(handler, { require, subject, name })`. +3. **After** — the same call now returns a typed `verification_required` refusal + (approve link + agent instruction) until the credential is proven, and the + change is pinned by a **load-bearing bypass test** (it goes red if the wrap is + removed). + +`server.mjs` exports `buildServer()` so the bypass test can drive the tool +in-memory (`InMemoryTransport.createLinkedPair()`) — no process spawning. diff --git a/examples/gate-my-tool-sample/server.mjs b/examples/gate-my-tool-sample/server.mjs new file mode 100644 index 0000000..2d9e3b0 --- /dev/null +++ b/examples/gate-my-tool-sample/server.mjs @@ -0,0 +1,42 @@ +// gate-my-tool-sample — the `gate-my-tool` skill's target: one UNGATED tool. +// +// npm run build --workspaces # build the packages once +// node examples/gate-my-tool-sample/server.mjs # serve release-records over stdio +// +// `release-records` performs a consequential disclosure with NO consent gate — +// deliberately. This server is the "before" in the before/after demo: ask your +// coding agent to run the `gate-my-tool` skill ("gate my release-records tool") +// and it becomes refuse-until-proven, plus a load-bearing bypass test. +// (Identity-first: nothing here is a purchase — no cart, no checkout.) + +import { fileURLToPath } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +// The consequential action: release a subject's record summary. +export function releaseRecords(subject) { + return { released: true, subject, records: [`record:${subject}:summary`] }; +} + +/** Build the MCP server (exported so a test can drive it in-memory). */ +export function buildServer() { + const server = new McpServer({ name: "gate-my-tool-sample", version: "0.1.0" }); + server.registerTool( + "release-records", + { + description: "Release a subject's record summary — a consequential action.", + inputSchema: { subject: z.string() }, + }, + async ({ subject }) => ({ + content: [{ type: "text", text: `Released records for ${subject}.` }], + structuredContent: releaseRecords(subject), + }), + ); + return server; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await buildServer().connect(new StdioServerTransport()); + console.error("gate-my-tool-sample: release-records served over stdio (UNGATED — run the gate-my-tool skill)"); +} From 2ce0fd3d8bfb51fd7b213d1cab40beb662bf1dc2 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 18:54:53 -0700 Subject: [PATCH 4/6] docs(#17): gate() quickstart + the three-enforcement-surfaces table (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate package README gains 'Gate a single tool' — the cold-reader- validated quickstart (full CallToolResult refusal shown, isError semantics, the provenBy footgun warning, the no-outputSchema rule, the honest approve-page note) and the one-policy-three-surfaces table. Root README gets the short teaser; api.md documents credentagent.gate(). Honest-status wording updated: enforcedAt "tool" ships via gate(); gated() is the deprecated predecessor. Signed-off-by: Diego Zuluaga Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4 Signed-off-by: Diego Zuluaga --- README.md | 19 ++++- docs/reference/api.md | 30 ++++++++ packages/credentagent-gate/README.md | 103 ++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8925571..7a3e090 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,21 @@ and the age gate drops — the `.when()` predicate receives the **order** and is For a deployment, pass your public origin: `new CredentAgent({ walletOrigin: "https://shop.example" })`. +**Not selling anything?** Gate a single MCP tool — no cart, no checkout — with +[`credentagent.gate()`](./packages/credentagent-gate/README.md#gate-a-single-tool--credentagentgate): + +```ts +server.registerTool("release-records", config, credentagent.gate( + async ({ subject }) => ({ content: [{ type: "text", text: `Released records for ${subject}.` }] }), + { require: age.over(21), provenBy: ({ subject }) => subject }, +)); +``` + +An unproven call returns a typed `verification_required` refusal (approve link + agent +instruction) instead of running; once the person proves the credential on their phone, the +same call goes through. One policy language, three enforcement surfaces — checkout page, +gated tool, delegated grant ([the table](./packages/credentagent-gate/README.md#one-policy-language-three-enforcement-surfaces)). + ## Documentation - **Reference** ([`docs/reference/`](./docs/reference/)): @@ -89,8 +104,8 @@ For a deployment, pass your public origin: `new CredentAgent({ walletOrigin: "ht security-bypass testing bar, and the module conventions. - **[docs/deployment.md](./docs/deployment.md)** — running it for real (serverless stores, the stable signing key, the settle seam) + a troubleshooting table. -- **[ROADMAP.md](./ROADMAP.md)** — what binds cryptographically today (Mode A) vs. what's - next (Mode B page-less gating, issuer-verified trust). +- **[ROADMAP.md](./ROADMAP.md)** — what binds cryptographically today (Mode A checkout + + Mode-B `gate()`-wrapped tools) vs. what's next (issuer-verified trust). ## Honest status diff --git a/docs/reference/api.md b/docs/reference/api.md index 1672f7c..c81a1d5 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -85,6 +85,36 @@ const requires = credentagent.requirements(order, [ ]); ``` +#### `credentagent.gate(handler, options)` — gate a tool (Mode B) + +```ts +gate(handler: (args: A) => R | Promise, options: GateOptions): + (args: A) => Promise +``` + +Wraps an MCP tool handler so it **refuses-until-proven**. An unproven call returns a +success-shaped tool result (`isError` unset) carrying the typed `verification_required` +envelope in `structuredContent` and an action-agnostic instruction in `content`; a proven +call runs `handler` unchanged. Built on the same resolver as `requirements()` +(entries carry `enforcedAt: "tool"`). + +- **`require`** — the `gate()`-effect credential(s) to prove (`age.over(21)`, a custom + `defineCredential`, or an array). `payment` / discount steps **throw at wrap time** — + they belong to the checkout ceremony, and a silently-unenforced step would fail open. +- **`provenBy`** — `(args) => string`; derives the id the proof is stored under (per + subject, never process-global). An empty value **throws fail-closed**. +- **`name?`** — your registered tool id; names the re-call in `resume.tool`. + +```ts +server.registerTool("release-records", config, credentagent.gate( + async ({ subject }) => ({ content: [{ type: "text", text: `Released records for ${subject}.` }] }), + { require: age.over(21), provenBy: ({ subject }) => subject }, +)); +``` + +Do **not** declare an MCP `outputSchema` on a gated tool — the envelope replaces the +success shape in `structuredContent`. + #### `credentagent.mount(app, ceremony?)` — Context 2 ```ts diff --git a/packages/credentagent-gate/README.md b/packages/credentagent-gate/README.md index e24a57a..95215be 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -56,6 +56,88 @@ the widget shows the confirmation. Add the headphones instead and the age gate d > predicate keys off the cart's lines — e.g. `order.lines.some((l) => l.minimumAge != null)`. > For a deployment pass your public origin: `new CredentAgent({ walletOrigin: "https://shop.example" })`. +## Gate a single tool — `credentagent.gate()` + +Not selling anything? Gate **any** MCP tool behind a credential — page-less Mode B. Your +handler is unchanged; wrap it and it refuses-until-proven: + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CredentAgent, age } from "@openmobilehub/credentagent-gate"; +import { z } from "zod"; + +const server = new McpServer({ name: "records", version: "1.0.0" }); +const credentagent = new CredentAgent(); // zero config for local dev + +server.registerTool( + "release-records", + { description: "Release a subject's record summary", inputSchema: { subject: z.string() } }, + credentagent.gate( + async ({ subject }) => ({ + content: [{ type: "text", text: `Released records for ${subject}.` }], + }), + { + require: age.over(21), // the credential to prove + provenBy: ({ subject }) => subject, // whose proof unlocks the call + }, + ), +); +``` + +Until the proof exists, the handler does not run. The agent receives a **normal, success-shaped +tool result** (`isError` is not set — don't let an error path swallow it) whose +`structuredContent` is the typed refusal, with a plain-English instruction in `content` +(detect it with `isVerificationRequired(result.structuredContent)`): + +```jsonc +{ + "content": [{ "type": "text", "text": "This action requires Age 21+ before it can run. Ask the person to open this link on their phone and present the credential: http://localhost:3000/credential-gate/age?order=casey — then call this tool again with the same arguments. …" }], + "structuredContent": { + "_credentagent": "verification_required", + "reason": { "gate": "Age 21+", "pass": false, + "detail": "Age 21+ must be proven before this action can run — no proof on file for \"casey\"." }, + "present": { "credential": "age", "min_age": 21, + "approve_url": "http://localhost:3000/credential-gate/age?order=casey", + "request": { /* the OpenID4VP query the person's wallet receives */ } }, + "resume": { "tool": "this-tool", "poll": "re-call with the same arguments until verification_required clears" }, + "trust_level": "presence-only-demo" + } +} +``` + +The loop: the agent shows the person `approve_url` → they prove the credential on their +phone → the proof is stored under `provenBy(args)` on this server — per subject, **never** +process-global → the agent re-calls the tool and the handler runs. `require` takes any +`gate()`-effect credential (or an array): `age.over(21)`, a custom `defineCredential`. +Payment and discounts are refused at wrap time — they belong to the checkout ceremony. + +- **`provenBy` is load-bearing.** It keys the proof. `provenBy: () => "global"` would let + the *first* person who verifies unlock the tool for **everyone** — derive a per-caller + id (user / session / subject). An empty value is refused fail-closed, never shared. +- **Don't declare an MCP `outputSchema` on a gated tool** — the refusal envelope replaces + the success shape in `structuredContent`, and the SDK would reject it against your schema. +- **The approve page:** `approve_url` is served by the ceremony `mount()` wires onto an + Express host (see the storefront quickstart above). A stdio-only server has no page of + its own yet — `gate()` warns at refusal time if nothing is mounted in the process, and a + standalone proving-page mount is a tracked follow-up. Stated, not faked. +- Optional: `name: "release-records"` makes the refusal name the re-call precisely + (`resume.tool`); it must match your registered tool id. + +Runnable target: [`examples/gate-my-tool-sample/`](../../examples/gate-my-tool-sample/) — +and the repo skill [`gate-my-tool`](../../.claude/skills/gate-my-tool/SKILL.md) lets a +coding agent install this wrap (plus its bypass test) on any existing tool in one shot. + +### One policy language, three enforcement surfaces + +| Surface | Scenario | Shape | You get | +| :-- | :-- | :-- | :-- | +| `requirements()` + `mount()` | human present · hosted checkout page | resolver | a `requires` manifest; the person completes it on your page | +| `credentagent.gate()` | human present · page-less tool | wrapper | a gated handler; the agent drives refuse → prove → re-call | +| `DelegatedGate.preApprove()` / `spend()` | human **not** present · delegated | stateful grant | a bounded grant agents spend later (demo-fenced preview) | + +All three enforce the same policy nouns (`age.over(21)`, `defineCredential`, …), so a +policy you write once reads the same on every surface. + ## The three execution contexts The split is load-bearing — conflating them is the documented root cause of confusion @@ -124,9 +206,9 @@ completion path (a hard block, independent of `required`/`optional`). Worked pac Honesty is carried in the **types**, not prose (Principle VII): -- **`enforcedAt: "checkout"`** — v0.1 is consolidated Mode A: every gate runs on the checkout page - (Context 2) and is enforced server-side on the completion path. (`"tool"` is the Mode-B blocking - shape — roadmap.) +- **`enforcedAt`** — where the gate is enforced, stated per entry: `"checkout"` for the + consolidated Mode-A page (Context 2, enforced server-side on the completion path), `"tool"` + for a `credentagent.gate()`-wrapped tool (Mode B — the wrap blocks the handler itself). - **`trust_level: "presence-only-demo"`** — the gate enforces *disclosure* (an explicit positive claim, not token-presence) and *binding* (nonce / ephemeral key), but **not trust** (mdoc issuer / device signatures). A self-crafted mdoc would pass. **This is a flow demo, not a real @@ -168,9 +250,11 @@ The cert's SubjectAltName must cover the `walletOrigin` host or the wallet rejec > whether the *wallet* trusts *us* to ask. It does **not** verify the mdoc the wallet presents > *back*, so `trust_level` stays **`presence-only-demo`** either way. -> **A refused tool call is a protocol, not a wall.** For a page-less tool, `gated()` returns a typed -> **`verification_required`** envelope the agent *drives* (which credential, a per-order approve link, -> the tool to poll) instead of completing — the retained blocking **Mode B** primitive. +> **A refused tool call is a protocol, not a wall.** For a page-less tool, +> [`credentagent.gate()`](#gate-a-single-tool--credentagentgate) returns a typed +> **`verification_required`** envelope the agent *drives* (which credential, a per-subject approve +> link, how to resume) instead of completing — the blocking **Mode B** primitive. (`gated()` is its +> deprecated v0-era predecessor, kept one minor version.) ## Delegated draws — human-not-present seams (005, preview) @@ -209,6 +293,7 @@ provide those are later increments. class CredentAgent { constructor(opts?: { walletOrigin?: string; store?: VerificationStore; credentials?: Credential[] }); requirements(order: GateOrder, policy: Step[]): VerificationManifestEntry[]; // Context 1 + gate(handler, { require, provenBy, name? }): wrapped handler; // Mode B: gate a tool mount(app: ExpressApp, ceremony?: MountCeremony): void; // Context 2 } @@ -229,9 +314,9 @@ sealIntent · checkDraw · signDraw · MemoryRevocationStore · Draw / I // signingKey-gated check in completeOrder + the opt-in `statelessOrders` transport issueCartMandate(args, secret) · verifyCartMandate(mandate, orderId, secret) · DEFAULT_CART_MANDATE_TTL_MS -// Retained Mode-B / roadmap blocking primitive -gated() · buildVerificationRequired() · isVerificationRequired() · envelopeInstruction() -ageDcql() · ENVELOPE_VERSION · ENVELOPE_SENTINEL +// Mode-B envelope helpers (credentagent.gate() emits these; gated() is the deprecated shim) +buildVerificationRequired() · isVerificationRequired() · envelopeInstruction() · gated() +ageDcql() · ENVELOPE_VERSION · ENVELOPE_SENTINEL · GateOptions // Types: CredentAgentOptions, GateOrder, OrderLine, Credential, Step, Effect, // VerificationManifestEntry, VerificationStore, VerificationRecord, From 0c8d21f9b1beea826df7b266e2afcccac46cbbc1 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 19:07:57 -0700 Subject: [PATCH 5/6] feat(gate,#17): provenBy receives the MCP per-request extra; round-4 DX fixes (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provenBy is now (args, extra?) => string — the SDK's per-request extra rides through, so a multi-user server keys proofs by the CALLER ((_args, extra) => extra.sessionId) instead of the requested resource. Round-4 cold readers caught that keying by the record's subject re-creates the shared-bucket bleed per record; the docs/skill/example now state the self-service assumption explicitly. New regression test pins the passthrough. Doc fixes from the same round: the stdio/approve-page paragraph hoisted and answered plainly, outputSchema rule strengthened, resume.tool default named, MCP result vocabulary + wire-JSON note, presence-only honesty line. ROADMAP Mode-B entry updated: gate() ships; the standalone proving-page mount is the next build. Sample README: stale pre-rename 'subject' option corrected. Signed-off-by: Diego Zuluaga Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4 Signed-off-by: Diego Zuluaga --- ROADMAP.md | 12 +++-- docs/reference/api.md | 6 ++- examples/gate-any-action.mjs | 3 +- examples/gate-my-tool-sample/README.md | 2 +- packages/credentagent-gate/README.md | 57 +++++++++++++-------- packages/credentagent-gate/src/gate.test.ts | 15 ++++++ packages/credentagent-gate/src/gate.ts | 20 +++++--- 7 files changed, 78 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fedda36..36d4cc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,11 +29,13 @@ claims about itself. ## Next -- **Mode B — gate any page-less tool.** The `verification_required` envelope primitive ships - today; the **page-less proving ceremony** (for hosts with no browser handoff — e.g. a CLI - agent gating `release-record`, `approve-deploy`, `file-prescription` with **no checkout at - all**) is the next build. This is where "gate **any** consequential action with **any** - credential" becomes runnable beyond commerce — the heart of the identity-first promise. +- **Mode B — the standalone proving page.** `credentagent.gate(handler, { require, provenBy })` + ships today: wrap any tool (`release-records`, `approve-deploy`, `file-prescription` — **no + checkout at all**) and it refuses-until-proven with the `verification_required` envelope. + What remains is the **standalone proving-page mount**: today `approve_url` is served by the + checkout-shaped ceremony seams, so a page-less host (stdio MCP, CLI) still needs a small web + process for the ceremony. A proving-only `mount()` — no orderStore/catalog/completion — is + the next build, and the top follow-up from the gate() cold-reader DX loop. - **Cart Mandate, end to end.** Issuance at checkout, `PaymentMandate` reconciliation by id, and the opt-in **stateless-orders** transport (the cart travels as the signed mandate, no shared order store). diff --git a/docs/reference/api.md b/docs/reference/api.md index c81a1d5..73cde7f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -101,8 +101,10 @@ call runs `handler` unchanged. Built on the same resolver as `requirements()` - **`require`** — the `gate()`-effect credential(s) to prove (`age.over(21)`, a custom `defineCredential`, or an array). `payment` / discount steps **throw at wrap time** — they belong to the checkout ceremony, and a silently-unenforced step would fail open. -- **`provenBy`** — `(args) => string`; derives the id the proof is stored under (per - subject, never process-global). An empty value **throws fail-closed**. +- **`provenBy`** — `(args, extra?) => string`; derives the id the proof is stored under + (per subject, never process-global). Key by the **caller** — the MCP per-request + `extra` (e.g. `extra.sessionId`) is the second argument; key by a tool arg only when + that subject is the prover. An empty value **throws fail-closed**. - **`name?`** — your registered tool id; names the re-call in `resume.tool`. ```ts diff --git a/examples/gate-any-action.mjs b/examples/gate-any-action.mjs index 520e73e..9f38366 100644 --- a/examples/gate-any-action.mjs +++ b/examples/gate-any-action.mjs @@ -28,7 +28,8 @@ const releaseRecords = credentagent.gate( async ({ subject }) => ({ released: true, subject, records: [`record:${subject}:summary`] }), { require: age.over(21), // the credential to prove — swap in any defineCredential - provenBy: ({ subject }) => subject, // whose proof unlocks the call (per subject, never global) + provenBy: ({ subject }) => subject, // self-service: the subject proves their OWN age + // (multi-user servers key by the CALLER: (_args, extra) => extra.sessionId) name: "release-records", }, ); diff --git a/examples/gate-my-tool-sample/README.md b/examples/gate-my-tool-sample/README.md index 469693a..2451756 100644 --- a/examples/gate-my-tool-sample/README.md +++ b/examples/gate-my-tool-sample/README.md @@ -14,7 +14,7 @@ node examples/gate-my-tool-sample/server.mjs # serves release-records over stdi and the records come back. No consent, no proof. 2. **Run the skill** — tell your coding agent: *"gate my `release-records` tool"*. The [`gate-my-tool`](../../.claude/skills/gate-my-tool/SKILL.md) skill wraps the - handler in `credentagent.gate(handler, { require, subject, name })`. + handler in `credentagent.gate(handler, { require, provenBy, name })`. 3. **After** — the same call now returns a typed `verification_required` refusal (approve link + agent instruction) until the credential is proven, and the change is pinned by a **load-bearing bypass test** (it goes red if the wrap is diff --git a/packages/credentagent-gate/README.md b/packages/credentagent-gate/README.md index 95215be..824be4a 100644 --- a/packages/credentagent-gate/README.md +++ b/packages/credentagent-gate/README.md @@ -58,8 +58,9 @@ the widget shows the confirmation. Add the headphones instead and the age gate d ## Gate a single tool — `credentagent.gate()` -Not selling anything? Gate **any** MCP tool behind a credential — page-less Mode B. Your -handler is unchanged; wrap it and it refuses-until-proven: +Not selling anything? Gate **any** MCP tool behind a credential — no page of your own, no +checkout anywhere (internally: the blocking **Mode B** surface). Your handler is unchanged; +wrap it and it refuses-until-proven: ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -78,16 +79,17 @@ server.registerTool( }), { require: age.over(21), // the credential to prove - provenBy: ({ subject }) => subject, // whose proof unlocks the call + provenBy: ({ subject }) => subject, // self-service: the subject proves their OWN age }, ), ); ``` Until the proof exists, the handler does not run. The agent receives a **normal, success-shaped -tool result** (`isError` is not set — don't let an error path swallow it) whose -`structuredContent` is the typed refusal, with a plain-English instruction in `content` -(detect it with `isVerificationRequired(result.structuredContent)`): +tool result** — the standard MCP fields `content` / `structuredContent`, with `isError` NOT +set (don't let an error path swallow it). `structuredContent` is the typed refusal (raw wire +JSON, snake_case), with a plain-English instruction in `content`; detect it with +`isVerificationRequired(result.structuredContent)`: ```jsonc { @@ -106,22 +108,35 @@ tool result** (`isError` is not set — don't let an error path swallow it) whos ``` The loop: the agent shows the person `approve_url` → they prove the credential on their -phone → the proof is stored under `provenBy(args)` on this server — per subject, **never** -process-global → the agent re-calls the tool and the handler runs. `require` takes any -`gate()`-effect credential (or an array): `age.over(21)`, a custom `defineCredential`. -Payment and discounts are refused at wrap time — they belong to the checkout ceremony. - -- **`provenBy` is load-bearing.** It keys the proof. `provenBy: () => "global"` would let - the *first* person who verifies unlock the tool for **everyone** — derive a per-caller - id (user / session / subject). An empty value is refused fail-closed, never shared. +phone → the proof is stored under `provenBy(...)` on this server — per subject, **never** +process-global → the agent re-calls the tool and the handler runs. + +**Who serves the approve page? Read this first if your server is stdio.** `approve_url` is +served by the ceremony `mount()` wires onto an Express host (see the storefront quickstart +above) — same process as an HTTP MCP server, or a small web process sharing the store. A +**stdio-only** server has no page of its own yet: `gate()` still refuses correctly, but the +link has nothing listening until you mount the ceremony somewhere — it warns at refusal time +if nothing is mounted in the process, and a standalone proving-page mount is a tracked +follow-up. Stated, not faked. + +- **`provenBy` is load-bearing — key it by the CALLER.** `provenBy: () => "global"` would + let the *first* person who verifies unlock the tool for **everyone**. On a multi-user + server use the MCP per-request context (passed as the second argument): + `provenBy: (_args, extra) => extra.sessionId`. Keying by a tool arg — as the example does — + is right only when the subject **is** the prover (self-service). An empty value is + refused fail-closed, never shared. - **Don't declare an MCP `outputSchema` on a gated tool** — the refusal envelope replaces - the success shape in `structuredContent`, and the SDK would reject it against your schema. -- **The approve page:** `approve_url` is served by the ceremony `mount()` wires onto an - Express host (see the storefront quickstart above). A stdio-only server has no page of - its own yet — `gate()` warns at refusal time if nothing is mounted in the process, and a - standalone proving-page mount is a tracked follow-up. Stated, not faked. -- Optional: `name: "release-records"` makes the refusal name the re-call precisely - (`resume.tool`); it must match your registered tool id. + the success shape in `structuredContent`, and the SDK would reject it against your + schema. Strip the schema when you gate an existing tool. +- `require` takes any `gate()`-effect credential (or an array): `age.over(21)`, a custom + `defineCredential`. Payment and discounts are refused at wrap time — they belong to the + checkout ceremony. +- Optional: `name: "release-records"` makes the refusal name the re-call precisely — + `resume.tool` stays the literal placeholder `"this-tool"` until you pass it; it must + match your registered tool id. +- A presence-only gate is a **flow demo, not a real age-verification control** — don't put + it in front of a genuinely restricted action until issuer-verified trust lands + ([Honest status](#honest-status)). Runnable target: [`examples/gate-my-tool-sample/`](../../examples/gate-my-tool-sample/) — and the repo skill [`gate-my-tool`](../../.claude/skills/gate-my-tool/SKILL.md) lets a diff --git a/packages/credentagent-gate/src/gate.test.ts b/packages/credentagent-gate/src/gate.test.ts index 38f0a36..ba658a4 100644 --- a/packages/credentagent-gate/src/gate.test.ts +++ b/packages/credentagent-gate/src/gate.test.ts @@ -177,6 +177,21 @@ describe("CredentAgent.gate", () => { expect(res.content?.[0]?.text).toContain("this tool"); }); + it("passes the MCP handler's `extra` through to provenBy — session-keyed proofs work", async () => { + const credentagent = new CredentAgent({ walletOrigin: "https://records.example" }); + const gated = credentagent.gate(async () => ({ content: [] }), { + require: age.over(21), + // The SDK calls handlers as (args, extra) — key by the transport session. + provenBy: (_args, extra) => (extra as { sessionId: string }).sessionId, + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const res = (await gated({}, { sessionId: "sess-9" })) as { structuredContent?: Record }; + warn.mockRestore(); + const env = res.structuredContent; + if (!isVerificationRequired(env)) throw new Error("expected envelope"); + expect(env.order.id).toBe("sess-9"); + }); + it("warns ONCE at refusal time when the ceremony is not mounted in this process (the approve link may be a dead end)", async () => { const { call } = gatedFixture(); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/packages/credentagent-gate/src/gate.ts b/packages/credentagent-gate/src/gate.ts index 978ae22..e834b32 100644 --- a/packages/credentagent-gate/src/gate.ts +++ b/packages/credentagent-gate/src/gate.ts @@ -21,13 +21,19 @@ export interface GateOptions { * ceremony (`requirements()` + `mount()`), and a discount is a benefit, not a gate. */ require: Credential | Credential[]; /** - * Whose proof unlocks the call — derive a stable id (a user / session / subject id) - * from the tool args. Calls that derive the same value share one proof; proofs are - * stored per-subject on this server's store, NEVER process-global (Security - * invariant 4). Returning a shared constant would let one person's proof unlock - * everyone's calls — an empty/missing value is refused fail-closed, not shared. + * Whose proof unlocks the call — derive a stable id for the CALLER. Calls that derive + * the same value share one proof; proofs are stored per-subject on this server's + * store, NEVER process-global (Security invariant 4). Returning a shared constant + * would let one person's proof unlock everyone's calls — an empty/missing value is + * refused fail-closed, not shared. + * + * On a multi-user server key by the caller, not the requested resource: the MCP + * SDK's per-request `extra` is passed as the second argument, so + * `(_args, extra) => extra.sessionId` keys per transport session. Keying by a tool + * arg (e.g. the record's subject) is right only when the subject IS the prover + * (self-service). */ - provenBy: (args: A) => string; + provenBy: (args: A, extra?: unknown) => string; /** Your registered tool name — names the re-call in the refusal (`resume.tool`). * Optional; without it the refusal says "this tool" (the agent knows what it called). */ name?: string; @@ -87,7 +93,7 @@ export function makeToolGate( let warnedUnmounted = false; return async (args: A, ...rest: unknown[]): Promise => { - const subject = opts.provenBy(args); + const subject = opts.provenBy(args, rest[0]); if (typeof subject !== "string" || subject.trim() === "") { // Fail CLOSED: a missing subject must never collapse callers into a shared // proof bucket (cross-user bleed, invariant 4) — refuse loudly instead. From 470b298cf88bacf4dca9fb97c704e3e05ac237f5 Mon Sep 17 00:00:00 2001 From: Diego Zuluaga Date: Mon, 20 Jul 2026 19:08:09 -0700 Subject: [PATCH 6/6] feat(skill,#17): gate-my-tool repo skill + TDD/DX evidence notes (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/skills/gate-my-tool/SKILL.md — wrap a named tool in credentagent.gate() with a load-bearing bypass test, in one shot. Authored with the writing-skills TDD loop: a no-skill RED baseline (which, honestly, SUCCEEDED on every core behavior thanks to this branch's fresh docs + CLAUDE.md — recorded as such; the skill's measured value is discovery compression, ~a-dozen source reads down to 2, plus trap insurance), a GREEN run with the skill (full pass incl. the wrap-removed red proof), and a REFACTOR pass folding back the GREEN auditor's five friction points. docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md — the spec-deviation record (maintainer-directed cold-reader DX loop, 4 rounds), bypass-mutation evidence, skill TDD evidence, and the follow-up list for needs-decision issues (standalone proving-page mount, walletOrigin naming, ?order= param, envelope field polish, CredentAgent naming). Also notes #17's stale 'discovery surface ships in v0.1' premise. Signed-off-by: Diego Zuluaga Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4 Signed-off-by: Diego Zuluaga --- .claude/skills/gate-my-tool/SKILL.md | 73 +++++++++++ ...07-20-gate-my-tool-implementation-notes.md | 116 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 .claude/skills/gate-my-tool/SKILL.md create mode 100644 docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md diff --git a/.claude/skills/gate-my-tool/SKILL.md b/.claude/skills/gate-my-tool/SKILL.md new file mode 100644 index 0000000..08d65f7 --- /dev/null +++ b/.claude/skills/gate-my-tool/SKILL.md @@ -0,0 +1,73 @@ +--- +name: gate-my-tool +description: Use when asked to gate an existing MCP tool behind a credential — "gate my tool", add a consent / age / credential check to a registered tool, or make a tool refuse-until-proven. Do NOT use for storefront/checkout gating (that's requirements() + mount()) or for building a new proving ceremony (add-ceremony-rail). +--- + +# Gate an MCP tool (refuse-until-proven) + +Install a CredentAgent consent gate onto an existing tool in **one honest call** — never +hand-roll the `verification_required` envelope, the store check, or the deprecated +`gated()` shim. `credentagent.gate()` absorbs all of it, and the wrap IS the server-side +enforcement point (Security invariant 1 — hiding a button is not enforcement). + +## Steps + +1. **Locate** the named tool's `registerTool(name, config, handler)` call, and the host's + `CredentAgent` instance. If the server has none, create ONE (`new CredentAgent()` — + zero-config for local dev), reuse it server-wide, and **export it**: its store is where + proofs live, and the bypass test must write proof onto the exact store the gate reads. +2. **Wrap** the handler — change nothing inside it: + + ```js + server.registerTool("release-records", config, credentagent.gate(handler, { + require: age.over(21), // TODO(dev): the real credential — any defineCredential works + provenBy: (_args, extra) => extra.sessionId, // whose proof unlocks the call — key by the CALLER + name: "release-records", // must equal the registered tool id exactly + })); + ``` + + - `require` takes bare `gate()`-effect credentials (one or an array) — no `required()` + wrapper. Payment / discount steps throw at wrap time by design. + - `provenBy` must derive a **per-caller** id — the MCP per-request `extra` (with + `sessionId`) is its second argument. Key by a tool arg ONLY when that subject is the + prover (self-service), never by the requested resource on a multi-user server. A + constant means the first person who proves unlocks the tool for EVERYONE + (invariant 4). Empty throws fail-closed. +3. **Delete any `outputSchema`** on that tool (no-op when only `inputSchema` is declared): + the refusal envelope replaces the success shape in `structuredContent`, and the SDK + validates `structuredContent` against a declared schema — the gate would break. +4. **Add the load-bearing bypass test** — in the project's existing test runner (vitest + here; a stray `node:test` file in a vitest glob fails the suite with "No test suite + found"). REQUIRED SUB-SKILL: follow `write-bypass-test`. + Drive the REAL server in-memory — `Client` from + `@modelcontextprotocol/sdk/client/index.js`, `InMemoryTransport.createLinkedPair()` + from `@modelcontextprotocol/sdk/inMemory.js`, `isVerificationRequired` from + `@openmobilehub/credentagent-gate` — using the exported `CredentAgent`. Assert the + attack precisely (each refusal prints the expected `[credentagent] gate(): … + mount(app) has not run` warning when no ceremony is mounted — not a failure): + - unproven call → the action did NOT happen (e.g. `structuredContent.released !== true`) + AND `isVerificationRequired(result.structuredContent)` with `reason.pass === false`, + `present.credential` === the credential's **id** (e.g. `"age"`), `present.min_age`, + and `order.id` === the `provenBy` value; + - proof written under the envelope's `order.id` → same subject unlocks, a **different** + subject is still refused (invariant 4); + - a record with the claim false/absent is refused — presence ≠ proof (invariant 5). +5. **Prove the test is load-bearing**: remove the wrap, run — red; restore — green. Say + which line you deleted. A bypass test that stays green with the control removed is not + a useful test. + +## Traps (each observed or blocked by design) + +| Trap | Reality | +| :-- | :-- | +| Gating in the page/HTML/prose only | The wrap is the enforcement point — server-side, every call | +| `provenBy: () => "shared"` | Cross-user bleed: one proof unlocks everyone | +| `provenBy` keyed by the requested resource | Same bleed per-record: anyone asking for casey's records rides one proof — key by the caller | +| Keeping `outputSchema` | SDK rejects the envelope against your schema | +| Hand-rolling `buildVerificationRequired` / using `gated()` | One call: `credentagent.gate()` | +| A second `CredentAgent` just for the gate | Proofs land in a store the ceremony never writes — share ONE instance | +| Test asserts only "not success" | Assert the typed refusal AND red-when-wrap-removed | + +Refusal wire shape + agent loop: package README, "Gate a single tool". Runnable before/after +target: `examples/gate-my-tool-sample/`. The refusal is a success-shaped result (`isError` +unset) — `trust_level` stays `"presence-only-demo"`; don't claim more than it proves. diff --git a/docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md b/docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md new file mode 100644 index 0000000..5348188 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md @@ -0,0 +1,116 @@ +# gate-my-tool — implementation notes & evidence + +Companion to [`2026-07-20-gate-my-tool-skill-design.md`](./2026-07-20-gate-my-tool-skill-design.md). +Branch `feat/17-gate-my-tool` · tracked in #92 (also #17, folds in the core of #23). + +## 1. DX deviation from the spec — maintainer-directed + +Mid-implementation the maintainer reviewed the spec's caller shape and rejected it +("if it's confusing we have failed"; iterate until Stripe-grade). The surface was +redesigned through a **cold-reader goal loop**: three rounds of two fresh-context +reader personas (a Stripe-fluent Node dev who's never seen MCP; an MCP server author +who's never seen wallets) each answering graded comprehension questions on the +quickstart alone. + +| | Spec (`gateTool`) | Shipped (`credentagent.gate()`) | +| :-- | :-- | :-- | +| Method | `gateTool(handler, opts)` | `gate(handler, opts)` | +| Policy | `require: [ required(age.over(21)) ]` | `require: age.over(21)` (credential or array; no `required()` noise — a blocking gate is required by definition; payment/discount **throw at wrap time**) | +| Scope key | `order: (args) => ({ id, total: 0, currency: "USD" })` | `provenBy: (args) => string` (no checkout vocabulary on identity actions; totals default internally) | +| Resume | — | optional `name` (defaults to a `"this-tool"` self-reference) | + +Reader-score trajectory: R1 4+4 → R2 5+6 → R3 6+6 out of 10. By round 3 both +readers had the loop and every option correct ("the `gate()` call itself I could +write blind right now"); remaining confidence loss was traced to two things **outside +this branch's scope** — see §3 — plus doc fixes that were applied (refusal shown as a +full CallToolResult, `isError` semantics stated, the `outputSchema` rule promoted, +`subject` renamed `provenBy` after both R3 readers independently misread it as the +*data* subject). + +## 2. Load-bearing proof for the bypass tests (write-bypass-test step 4) + +Each control was temporarily disabled, the suite run, red confirmed, control +restored (288/288 green after): + +| Mutation (in `gate.ts`) | Invariant | Red tests | +| :-- | :-- | :-- | +| A — `entries.find((e) => !proven(e, record))` replaced with `undefined` (proven-check deleted) | 1 (enforce on the completion path) | 6/9, incl. all four security tests | +| B — `record?.ageVerified === true` weakened to `record != null` (token-present) | 5 (explicit positive claim) | "REFUSES a negative/absent claim" | +| C — `opts.provenBy(args)` replaced with a shared constant | 4 (never process-global) | "REFUSES a cross-subject bleed" (+3) | + +## 3. Follow-ups surfaced by the DX loop (not this branch) + +For `needs-decision` issues — each was flagged independently by multiple cold readers: + +1. **Standalone proving-page mount for page-less servers.** `mount()` requires the + checkout-shaped seams (orderStore/catalog/completion), so a stdio-only MCP server + has no one-line way to serve `approve_url`. This was the single biggest remaining + confidence blocker in round 3. `gate()` warns honestly today. +2. **`walletOrigin` naming.** Flagged by every reader in every round ("actively + misleading — name says wallet, value is MY server"). Recommend an alias + (`serverOrigin` or `publicOrigin`) with `walletOrigin` kept for compat. +3. **`?order=` query param on approve links** for identity actions (rail contract) — + reads as checkout leakage on a non-commerce gate; recommend accepting a neutral + alias param on the credential rail. +4. **Envelope v1 field polish** (wire contract, needs a version bump): `present` + noun ambiguity, `reason.pass` redundancy, prose in `resume.poll`. +5. **`CredentAgent` class name** — repeat reader stumbles ("'agent' reads as the AI + caller"; "a portmanteau I had to sound out"). Product-level rename question. + +Also noted per spec §5: #17's premise that a discovery surface (`agents.md`, #20) +"ships in v0.1" is stale — it does not exist in the repo. + +## 4. Skill TDD evidence (writing-skills RED → GREEN) + +**RED baseline (no skill).** A fresh-context agent, told only *"gate my release-records +tool so it requires age verification (21+); add whatever tests you think are needed"* +against an untracked copy of the ungated sample, forbidden from reading `.claude/skills/`. + +Outcome — an honest surprise: the baseline **succeeded on every core behavior.** It chose +`credentagent.gate()` over hand-rolling, keyed proof with `provenBy` per subject, omitted +`outputSchema` (verifying the SDK's validation path itself), injected one shared +`CredentAgent` for testability, wrote three precise attack tests (bypass, cross-subject +bleed, negative-claim), and ran the wrap-removed mutation proof unprompted (3 pass → wrap +removed: 3 fail → restored: 3 pass). Root causes: this branch's fresh README/api.md/jsdoc +were already committed, the repo's CLAUDE.md carries the invariants + the bypass-test bar, +and the sample README states the intended before/after. It even caught a stale +pre-rename `subject:` reference in the sample README (fixed). + +Per the writing-skills discipline, that means the skill is NOT justified as a correction +of observed failures **in this repo's environment**. It is kept (as the spec requires) as +**compression + trap insurance**: the baseline burned substantial discovery (reading +`gate.ts`, `client.ts`, and SDK internals before writing the wrap), and its success leaned +on docs an agent may not read on a worse day. The skill distills the checklist — wrap, +`provenBy` footgun, delete `outputSchema`, single shared client, the write-bypass-test +loop — into one page with the traps stated explicitly. + +**GREEN verification (with skill).** A second fresh-context agent, same task, told to +follow the skill, against another ungated copy. Full pass: correct wrap (shared exported +`CredentAgent`, per-caller `provenBy`, `name` matching the tool id), three precise attack +tests, and the load-bearing proof run as instructed (3 pass → wrap deleted: 3 fail → +restored: 3 pass; it named the deleted line). **Discovery cost for the wrap fell from +~a-dozen source reads (baseline) to 2** (the skill + the target file); its remaining reads +went to test assertions and SDK import specifiers. Its audit listed five friction points +(unstated import specifiers, envelope field paths, export-the-client placement, an N/A +step, the expected unmounted warning) — all folded back into the skill (REFACTOR phase), +plus one skill correction from the harness itself: vitest does **not** skip dot-dirs, so +the skill now says "use the project's test runner." + +## 5. DX goal-loop verdict (round 4, on the shipped README section) + +Round 4 (fresh readers, final committed text, sample linked): the wrap + agent loop are +solved — the MCP persona rated coding the detect/re-call loop "trivial… a 9" and both +walked every step correctly. Remaining scores (6 and 4–6) trace to exactly two things: + +1. **The standalone proving-page mount** (§3.1) — both personas' top lookup. A capability + gap, not confusion; stated honestly in the README and ROADMAP, tracked as the next + increment. +2. **`provenBy` example semantics** — round 4's one new catch: keying by the *requested + record* on a multi-user server re-creates the shared-bucket bleed per record. Fixed + three ways: `provenBy` now receives the MCP per-request `extra` as its second argument + (so `(_args, extra) => extra.sessionId` keys by caller; new regression test), the + README/skill/example annotate the self-service assumption explicitly, and the skill's + trap table names the resource-keying variant. + +With those landed, every reader-flagged item is either fixed, honestly fenced with a named +follow-up (§3), or a wire-contract question deferred to an envelope version bump.