Skip to content

Gate a single tool in one call — credentagent.gate() + the gate-my-tool skill (#17) - #94

Draft
dzuluaga wants to merge 6 commits into
mainfrom
feat/17-gate-my-tool
Draft

Gate a single tool in one call — credentagent.gate() + the gate-my-tool skill (#17)#94
dzuluaga wants to merge 6 commits into
mainfrom
feat/17-gate-my-tool

Conversation

@dzuluaga

@dzuluaga dzuluaga commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #17. Draft — will be split into two smaller PRs (the function, then the skill).

In plain terms

This adds one function a developer wraps around any AI-agent tool to make it require the user's consent before it runs — plus a skill that adds that wrapper automatically. Concrete example: a tool that releases someone's medical records won't hand them over until the user has proven they're allowed to see them. If they haven't, the tool asks them to prove it first, then runs.

What you're approving

  • credentagent.gate(handler, …) — a new library function that wraps a tool so an unproven call is refused (the tool returns "prove this first" instead of running).
  • A skill (gate-my-tool) that installs that wrapper — you tell a coding agent "gate my release-records tool" and it does it in one step, and writes the test that proves it worked.
  • A small example tool to run the skill against.

It's opt-in — existing tools are unchanged unless a developer wraps them. Worst case if the code is wrong: a tool that should refuse an unproven caller runs anyway — so there's a test that fails the instant that protection breaks (step 2 below).

How to test

1 · Run the skill (the real test — "one command to gate a tool"):
The skill is built into this repo, so Claude Code picks it up automatically on this branch. Point it at the ungated example (examples/gate-my-tool-sample/server.mjs) and say "gate my release-records tool." It wraps the handler in credentagent.gate(...) and writes a test. Then confirm:

npm run build --workspaces && npm run test --workspace=@openmobilehub/credentagent-gate

git checkout examples/ packages/ to undo the demo edits afterward.

2 · Prove the protection is real (~30s):
In packages/credentagent-gate/src/gate.ts, add return true; as the first line of proven() (this fakes "everyone is proven"), re-run the gate tests → the test named "REFUSES an unproven gated action" goes red. Restore the line → green. That's proof the gate actually blocks, not just decorates.

3 · The deterministic tests:

npm run test --workspace=@openmobilehub/credentagent-gate       # 324 pass
npm run test --workspace=@openmobilehub/credentagent-storefront # 86 pass

For reviewers — the detail

credentagent.gate() replaces ~60 lines of hand-rolled refusal-envelope plumbing (and the deprecated gated() shim) with one wrap. It's action-agnostic — the example gates a records release, not a purchase, because identity leads and payment is just one credential; that's why it's a top-level method, not under a commerce resource (the naming was settled in favour of credentagent.gate() over orders.gate; see #95 / feature 009).

The three security-critical tests in gate.test.ts: "REFUSES an unproven gated action" (the core control) · "cross-subject bleed — casey's proof never unlocks riley" (per-user scoping) · "a negative/absent claim is not proof" (must be an explicit yes).

Files: the function + tests (gate.ts, gate.test.ts, small edits to client.ts/index.ts/envelope.ts/gated.ts), the gate-my-tool skill, the sample server, and README/docs. Rebased on main after #91 and #84 landed (kept both seams in mount.ts, kept your reader-identity README section).

Note: to be split — PR 1 = the credentagent.gate() function + its tests (careful review), PR 2 = the skill + sample (lighter), stacked on PR 1.

dzuluaga and others added 6 commits July 21, 2026 18:09
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 <dfzuluaga@gmail.com>
)

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 <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…he facade (#92)

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 <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
)

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 <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…DX fixes (#92)

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 <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
.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 <dfzuluaga@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9tR5vZy7jPw8pZHzSFkV4
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
credentagent-demo Ready Ready Preview, Comment Jul 22, 2026 1:12am

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 470b298cf8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
}
// 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: [] };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make gate-only approve URLs usable

When gate() is used with the documented mounted-ceremony flow, this fabricated zero-line order is never persisted in the ceremony's orderStore. The generated /credentagent/credential?order=<subject> link is therefore rejected as “Order not found” by credential-gate/routes.ts because that route resolves every ID through resolveOrder before serving or verifying a credential. As a result, mounting removes the warning but does not let a normal gate-only tool obtain proof; the handler remains permanently refused unless application code writes the verification store directly.

Useful? React with 👍 / 👎.

@dzuluaga

Copy link
Copy Markdown
Contributor Author

Deep review — credentagent.gate() (branch feat/17-gate-my-tool)

Reviewed against the repo checklist (REVIEW.md), the six security invariants, the honesty-fencing rule, and the developer-experience rubric. I checked out the branch, ran both test suites (gate 324 pass, storefront 86 pass, build + lint green — matches the PR), and verified every finding below with a runnable repro rather than by eye.

Verdict, in one paragraph. The security core of gate() is solid: I deleted three of its protections in turn and the tests caught each one, so the "prove-before-you-run" guarantee is real and pinned. There is no way to trick a gated tool into running for someone who hasn't proven — every failure mode I tried refuses safely (fails closed). But the headline promise — "wrap a tool in one call and the agent drives the person through proving it on their phone, then the tool runs" — does not actually complete end-to-end in any setup a developer can ship today. The tool refuses correctly and hands back an approve link, but that link leads to a page that rejects the request, so the person can never finish proving and the tool stays locked forever. The tests hide this because they skip the real proving step and write the proof straight into storage. This is safe (the tool never wrongly runs), but it means the flagship feature is a refusal without a finish, and the docs describe the finish as if it works. I'd treat closing that loop — or fencing the promise honestly — as the thing to resolve before this is presented as done. Two smaller correctness/DX issues follow. I did not approve or request changes; this is input for the maintainer.

Because the PR says it will be split (the function first, the skill second), everything below is about the function.


Finding 1 — the approve loop never completes; a gated tool can refuse but can never be unlocked through the real ceremony (High; correctness + honesty)

What breaks. gate() identifies the caller with a string it calls the subject (e.g. casey) and builds the whole refusal around a synthetic $0 order whose id is that subject, with no line items (gate.ts:106). The approve link it returns therefore points the person at the credential-proving page as …/credentagent/credential?order=casey&cred=age (manifest.ts:55). But every route on that page first re-loads the order by id from the order store and re-prices it from the catalog — and a subject like casey was never created as an order, so the lookup returns nothing (mount.ts:224-225) and the verify endpoint answers HTTP 400 missing or invalid order (credential-gate/routes.ts:242). No proof is ever written, so the re-call refuses again, indefinitely.

Concrete scenario (verified). I mounted the ceremony exactly as the README's quickstart shows (real order store, catalog, completion seam), wrapped a release-records tool, and drove the loop the way an agent would:

  • gated call with no proof → refusal, approve_url = https://records.example/credentagent/credential?order=casey&cred=age ✓ (as documented)
  • POST the age proof to that rail ({ order: "casey", cred: "age", claims: { age_over_21: true } }) → 400 missing or invalid order
  • verification store for caseyempty
  • re-call the tool → still refused

The unit tests in gate.test.ts pass only because they bypass the ceremony entirely — they call store.write(subject, { ageVerified: true }) directly (e.g. gate.test.ts:79), which no real deployment can do. So the suite proves the refusal half but never exercises the completion half.

Why it matters for honesty. The package README's "The loop" section describes proving on the phone and the handler then running, and its "Who serves the approve page" note says a stdio server "still needs a small web process … until you mount the ceremony somewhere" — both imply that mounting the existing ceremony closes the loop. It does not: even fully mounted, the link 400s. ROADMAP.md is closer to honest ("a proving-only mount() … is the next build"), but it frames the missing piece as ergonomic ("still needs a small web process") when it is actually a functional prerequisite — there is no mount today that can serve a gate() approve link. Under this repo's "never overclaim" bar that gap should be stated plainly where a developer first meets it.

Severity. High as a correctness/DX/honesty issue; not a security hole — it fails closed (the tool never runs unproven).

Suggested fix (pick one). Either (a) land the proving-only mount() the roadmap names, so a page-less subject can actually prove — the real close of the loop; or (b) until then, state at the top of the gate() README section and the envelope docs that the approve link has no server that can complete it yet (proving-page mount is a tracked follow-up), so gate() is shipped as "refuses correctly; completion pending" rather than a working round trip. A test that drives the real rail (not a direct store write) would have caught this and would guard the fix.


Finding 2 — a .when() condition that reads the cart silently disables the gate on a tool (Medium; fail-open foot-gun)

What breaks. A gated tool's synthetic order has no line items. If a developer attaches a call-site condition that inspects the cart lines — the exact style the README's own checkout example teaches, age.over(21).when((order) => order.lines.some((l) => l.minimumAge != null)) — the condition is always false on a tool, so the resolver drops the gate (manifest.ts:76-79), gate() finds nothing left to enforce (gate.ts:109), and the handler runs unproven.

Concrete scenario (verified). I wrapped a tool with that predicate and called it with no proof: the handler ran and returned its secret payload. No error, no warning — the gate simply vanished.

Severity. Medium — it needs the developer to reuse a cart-shaped condition, but that pattern is exactly what the sibling docs model, and the result is the worst kind (a gate that looks present but silently isn't).

Suggested fix. When the wrapped credentials all resolve to zero applicable entries at call time (every gate filtered out by its own .when()), gate() should warn once — "gate(): every required credential was filtered out by its .when() for this call; the handler ran ungated" — mirroring the existing not-mounted warning. Optionally note in the gate() docs that .when() predicates on a tool see an order with no lines.


Finding 3 — the age check trusts a bare "verified" flag and ignores the threshold it was proven at (Low; invariant 5, latent)

What breaks. For age, the proof test is record?.ageVerified === true (gate.ts:52) — it never compares against the gate's own threshold (entry.minAge, which the resolver does populate). The stored record carries only a boolean, not the age it was proven at. So if the same store and the same id ever receive an age_over_18 proof (for instance a checkout ceremony sharing one store and an id that collides with a subject), it would satisfy a gate(age.over(21)) — the "an 18+ proof must not satisfy a 21+ gate" rule.

Severity. Low and latent — it needs a shared store plus an id collision between a checkout order and a gate subject, and today the loop can't write ageVerified for a gate subject at all (Finding 1). Worth recording so it isn't lost when Finding 1 is fixed.

Suggested fix. Persist the proven threshold (e.g. ageVerifiedOver: 21) and have proven() require record.ageVerifiedOver >= entry.minAge, so the boolean can't be reused across thresholds.


What's genuinely solid (so the fixes don't regress it)

  • The three core protections are load-bearing. I deleted each and a named test went red: forcing the proof check to always pass → "REFUSES an unproven gated action" fails; removing the empty-subject guard → "fails CLOSED on an empty subject" fails; keying the store read to a constant instead of the subject → the cross-subject-bleed test fails. This is exactly the bypass-test discipline the repo asks for.
  • Per-caller scoping (invariant 4) and explicit-positive claims (invariant 5) are enforced and tested, and an empty/missing subject refuses loudly instead of collapsing everyone into one shared bucket (gate.ts:97-104).
  • The wrap itself is clean DXcredentagent.gate(handler, { require, provenBy }) needs no store/resolver plumbing; the seams are bound inside the client (client.ts:110-128).
  • Honesty on trust is correcttrust_level stays presence-only-demo, and the README fences it ("a flow demo, not a real age-verification control … until issuer-verified trust lands"). No issuer-verified overclaim. Finding 1's honesty point is specifically about the loop completing, not about the trust level.
  • Fails fast at wrap time on a policy it can't honor — payment, discount, or an empty require all throw at wrap, not silently at call time (gate.ts:75-91).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude skill attestomcp-gate-my-tool — agent-native one-command install

1 participant