Skip to content

Standalone Handshake V1 — witnessed, bounded A2A channel at /connect/mcp - #115

Merged
thetangstr merged 29 commits into
mainfrom
feat/standalone-handshake
Sep 17, 2026
Merged

thetangstr merged 29 commits into
mainfrom
feat/standalone-handshake

Conversation

@thetangstr

Copy link
Copy Markdown
Owner

Summary

  • New product module packages/mcp-server/src/standalone-handshake/: the pre-negotiation, mutually authenticated gateway for two previously unconnected agents — ERC-8004 identity + signed authority statements + deterministic capability-manifest checklist, signed consent over identical terms, then a bounded channel whose opening (three chained anchored transitions) and closure are ledger receipts. Implements the approved stakeholder spec (docs/superpowers/specs/2026-09-14-standalone-handshake-design.md, from the Standalone Handshake requirements doc).
  • Ten-tool credential-light MCP surface at /connect/mcp (no API key — two strangers must be able to connect): handshake_invite, handshake_accept_invitation, handshake_status, consent_sign, channel_open, channel_send, channel_read, channel_status, channel_close, channel_revoke, with role-access handles (sliding TTL), per-IP rate limits, fail-closed admission (exact reason codes: NOT_OPEN / EXPIRED / REVOKED / SCOPE_VIOLATION / TOO_LARGE / UNKNOWN_PARTY), lazy consensus-anchored expiry, and immediate one-sided revocation.
  • Verifiable receipts: opening anchors terms-readinessconsentopen as predecessor-chained transition records (the consent/open records carry both parties' consent digests); closures anchor a pinned closure record; open times derive from the anchor's consensus block time. Keylessly verifiable through the existing public verify path.
  • Wired into the main server: /connect/mcp + /.well-known/standalone-handshake.json; landing page section, module card, and llms.txt announce the capability (consent covers communication only — never an agreement, never a transaction).
  • The v2 agent-handshake and handshake modules are untouched — Standalone Handshake is its own productization track per the spec.

Test plan

  • Full suite: 377/377 pass, 0 fail / 0 skipped / 0 todo (cd packages/mcp-server && npm test) — includes protocol exact-key validation, the full state-machine edge table, expiry at exactly expiresAtMs, revocation mid-conversation, checklist cross-check matrix, fault-injection (transient anchor failure at open/closure leaves the session retryable and re-anchors idempotently), broker sliding-TTL, error mapping (reason codes surface, internals suppressed, structured failure logs), and an end-to-end two-party run over real HTTP from invitation to anchored revocation.
  • Whole-branch review (separate reviewer pass) findings fixed and re-reviewed: anchor-before-mutate ordering, different-digest fail-hard guard, safe error mapping + telemetry, sliding handle TTL, consent-digest binding.
  • git diff confirms no changes under src/agent-handshake/ or src/handshake/.
  • Deploy to staging and run the flow between two real agent clients (next step; V1 target is staging-connected, single validator).

🤖 Generated with Claude Code

thetangstr and others added 27 commits September 14, 2026 12:52
…connect/mcp endpoint

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tocol validators to e2e anchored closure

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…+'1b', not repeat(32))

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cal digests, consent/authority payloads

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…uthority signature, manifest cross-check

Checklist checks identity (policy-gated resolution), authority signature
recovery against sessionKeyAddress, and manifest cross-consistency, with a
canonical checklist digest over schema clockchain.standalone-handshake-checklist/v1.
Centralize validTerms/validReadiness test fixtures in test/helpers/ so no test
file imports another (avoids double execution under node --test); controller
sign-off: test 2 exercises the resolver via a required_fresh policy.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…s, fail-closed admission

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ning receipt, closure anchoring

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rver at /connect/mcp

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…handshake.json on the main HTTP server

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… llms.txt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chored revocation over HTTP

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h consensus-derived times

channel_open now anchors all three transitions before setStage/openChannel, so a
transient anchor failure leaves the session in consented and the retry idempotent.
openedAtMs derives from the open anchor's block time (ledger consensus time), and
the CONSENT and OPEN transition records carry both parties' consent digests.
Closure records are pinned in the store before anchoring (setPendingClosure/
takePendingClosure) so retries re-anchor the identical digest; anchoring fails
hard when a reference already holds a different digest. Spec doc notes both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tool layer

Admission refusals surface the bare reason code; retryable names keep the
temp-unavailable shape; everything else (including coordinator failures and
unknown errors) returns the constant STANDALONE_HANDSHAKE_UNAVAILABLE — raw
messages never reach unauthenticated clients. Failures also emit the v2-style
structured log line (event/tool/errorName) before the error body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ping on every broker path

Resolving a live handle now refreshes its expiry (TTL from last use, not from
issue) so a handle survives channels longer than one fixed horizon while an
idle handle still expires. Role-scoped results also strip initiatorAccess/
responderAccess, matching the v2 broker's defense-in-depth posture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd pin the boundary tests

A non-string or empty channel_send body is now refused with MALFORMED (per the
spec's reason-code list); only a well-formed string over the byte cap is
TOO_LARGE. Adds boundary-accept tests (durationSeconds 60/86400,
maxMessageBytes 1/16384 accept, 0 rejects), checklist fail-closed and
digest-sensitivity tests (a throwing recoverAddress yields AUTHORITY_INVALID;
a failing checklist digests differently), and a one-line spec note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-closure semantics

The store gains sessionIds() (the single source of truth for auth scans),
terminal-stage eviction with a terminalRetention cap (default
TERMINAL_SESSION_RETENTION = 500; active sessions are never evicted), frozen
scope/checklist copies from status(), and UNKNOWN_PARTY validation on
closeChannel/revokeChannel. The coordinator drops its private cachedIds cache.

Closure is now fail-safe end to end: channel_close/channel_revoke pre-touch the
store and refuse an expired channel with EXPIRED before any ledger write, and
the closure record is pinned peek-then-clear-late — setPendingClosure keeps a
like-for-like pin (retry re-anchors the identical digest) and refuses a
different outcome or role with CLOSURE_PENDING before anchoring; the pin clears
only after the store mutation succeeds, so it survives anchor and mutation
failures alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…o-end coverage top-ups

The /connect/mcp catch now emits a structured warn event before the 503 so a
lazy handler failure is visible in logs, not silently swallowed. The landing
test asserts the zero-padded "08" module badge directly instead of the vacuous
includes(MODULE_COUNT) check, and the e2e flow covers the opened channel's
status stage, a second in-scope send at seq 2, and both-direction toRole read
filtering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…re rate limits, retryable accept, bounded store

Adversarial audit of the v1 surface found five real defects; all are fixed
with regression tests:

- Rate limits collapsed to one global bucket: the listener binds ::, so the
  trusted proxy peer arrives IPv6-mapped (::ffff:172.30.0.3) and never
  string-equals the configured 172.30.0.3 — XFF was ignored and any client
  could exhaust the whole endpoint's budget. Normalize mapped addresses
  before comparing (standalone + v2 public servers).
- Time judgments ran on the server wall clock: the runtime coordinator now
  reads Clockchain consensus time via clock-sdk (transient-refusal before
  first sync), and channel_open fails transiently on an unparseable anchor
  block time instead of silently falling back to Date.now.
- A transient identity-resolution failure during accept bricked the session
  in readiness_pending forever: the claim is restored and the stage rolled
  back so the same invitation retries cleanly; a tampered envelope restores
  the claim too.
- The discovery manifest hardcoded the production /connect/mcp endpoint and
  served it under /staging/*: the endpoint now derives from an env override
  or the request's forwarded host/prefix, and the Caddyfile gains the
  committed handle_path /staging/* route (previously an uncommitted on-box
  edit) with X-Forwarded-Prefix.
- Unbounded memory: per-session message cap, idle TTL for non-terminal
  sessions, TTL for unclaimed invitations, terminal-session retention cap,
  and limiter-map sweeping.
- Mixed-case (checksummed) hex is accepted and canonicalized to lowercase so
  digests and the recovered-address compare are casing-independent; the
  pinned registry address canonicalizes the same way.
- channel_send's schema no longer pre-empts the spec'd reason codes: a
  non-string body reaches admission and surfaces MALFORMED.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…s sub-ms

ClockchainClock.now().epochMs carries monotonic sub-millisecond noise, so
closedAtMs/sentAtMs reached decimal-validated records as floats and the
closure record refused with a validation error — channel_revoke failed
opaque on the live staging flow. Floor at the runtime binding and at the
two record use sites so any injected clock stays safe.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…e demo theater

- GET /connect/verify?ledgerId&blockHeight — public, unauthenticated route
  that verifies an anchor against the immutable on-chain block (no API key
  on the upstream read). Receipts are only meaningful if anyone can check
  them; the node network is not browser-reachable, so the host fronts the
  same keyless check. Per-IP limited, positive results cached (immutable).
- packages/web-demo: handshake theater demo — two agent personas with fresh
  ephemeral Sepolia keypairs sign EIP-191 locally and run the real flow
  against staging: invite, checklist, dual consent, three opening anchors,
  bounded exchange, anchored revocation. SSE streams every beat to a
  three-pane page (initiator | ledger witness | responder) with scripted
  fault injection (forged signature, mismatched manifest, pre-open send,
  out-of-scope kind, oversized body, replayed invitation, forged handle,
  send-after-revoke) so the fail-closed reason codes are part of the show.
  "Verify anchors keylessly" calls /connect/verify; past runs replay from
  their stored event logs.
- mcp-server gains a narrow exports map so the demo imports the protocol
  records (canonical bytes must match the server's byte-for-byte).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Adds a second demo mode where the page visitor plays Agent B: an
ephemeral Sepolia keypair is generated in the tab, authority + consent
records are signed with the bundled viem IIFE (canonical sorted-key
bytes identical to the coordinator's), and only signatures cross the
wire via a transport-only tool proxy. The demo server hosts the Agent
A initiator persona end-to-end.

Also fixes proxied tool-call payloads to tolerate plain-text MCP error
bodies, and pins esbuild for the bundle step.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Transitional change. The host now publishes its exact active terms in
discovery, which the coordinator's exact parser previously rejected
outright, and invite() minted sessions for arbitrary caller terms the
host would never drive (deadlocking at awaiting_descriptor). Until the
terms-bearing host is fully rolled out, discovery() accepts both the
legacy shape without terms and the new shape with valid normalized terms.
When host terms are present they are authoritative: a canonically
different caller terms object fails before minting, state creation, or
relay posting, and the published terms flow to metadata, statement
digests, policy, the relay body, and the response. When absent, the
legacy caller-terms path is preserved and one bounded structured
telemetry event (event name + sessionId only, no terms or secrets) is
emitted so rollout completion is observable. The post-claim storeInitial
terms-rotation rejection is deliberately deferred until invitation-claim
durability lands.

Constraint: deployment skew — a strict terms requirement would break every invite while any pre-terms host session is still being served
Constraint: public error surface stays generic — mismatch surfaces as HANDSHAKE_UNAVAILABLE, non-retryable
Rejected: strict terms requirement now | requires verified host rollout first; this commit is the safe intermediate state
Rejected: post-claim storeInitial terms check now | runs after the invitation is durably claimed and would burn it on rotation
Confidence: high
Scope-risk: narrow
Directive: the legacy fallback and telemetry are transitional — remove both in the strict follow-up once claim durability and host rollout are verified
Tested: node --test coordinator+public-server (12 pass); npm test -w @clockchain/mcp-server (406 pass)
Not-tested: live relay; one flaky suite run observed once then passing on rerun
Invitation acceptance previously burned the claim before the responder
was durably seated, so any failure after claim made the invitation
unrecoverable. Store schema v2 now persists a durable claim record
(phases claimed/initialized/posted/completed) keyed by
HMAC(serverKey, clientKey) plus the responder mint fields, so the same
invitation plus same client-held key resumes monotonically and returns
byte-identical responder access through access expiry.

Constraint: only the HMAC digest and key id persist; raw invitations,
raw acceptance keys, and responder access tokens never reach disk.
Constraint: caller entropy is required for durability so a second
invitation holder cannot replay as the same claimant.
Rejected: invitation-derived idempotency | any holder could recompute it.
Rejected: persisting the minted responder access | reconstruction from
persisted kid/jti/nbf/exp plus the retained signing key is equivalent
and keeps tokens out of the store.
Confidence: high
Scope-risk: narrow
Directive: accept() returns the claim record for the coordinator to
drive through advanceClaim; coordinator phase recovery and relay
idempotency are NOT yet wired. v1 stores migrate at load: unclaimed to
claim:null, claimed to legacy_terminal which can never reconstruct
access. A missing key still performs a legacy one-shot terminal claim.
Tested: 20/20 invitation-store tests (v1 migration, key hygiene,
restart reconstruction, signing/HMAC rotation, expiry bounds);
full @clockchain/mcp-server suite 423/423; tsc build clean.
Not-tested: coordinator accept-phase ordering, relay duplicate
identity, and public-tool acceptanceIdempotencyKey surface.
A keyed accept now resumes monotonically instead of burning the
invitation: begin-or-resume the durable claim, insert-or-validate
responder state, advance initialized, post the claim receipt
idempotently, advance posted, then advance completed after the response
is built. A crash at any point retries with byte-identical responder
access, the preserved relay identity, and one logical relay message.

Constraint: storeInitial preserves an existing responder relay key and
sender key, and rejects mismatched principal/session/role, terms,
discovery, certificate, or relay identity rather than rotating.
Constraint: refreshed discovery is re-fetched before state mutation;
published terms must canonically match invitation metadata, while a
legacy record without terms still passes with bounded event+session
telemetry for mixed-version rollout recovery.
Rejected: JSON.stringify body equality for relay dedup | key order can
differ; canonical digests bound to session/role/senderKey/kind make
exact duplicates a no-op and conflicting duplicates terminal.
Rejected: relying on invitation-store serialization alone | the relay
post window between begin and completion needs same-key in-process
serialization keyed by durable claim identity.
Confidence: high
Scope-risk: narrow
Directive: public-tool acceptanceIdempotencyKey schema, instructions,
and runtime HMAC env wiring (AGENT_HANDSHAKE_ACCEPTANCE_HMAC_*) remain
unwired; keyed accepts only reach the service when a caller injects the
key through invoke. No cross-process file-store safety is claimed.
Tested: focused invitation-store + coordinator 38/38; full
@clockchain/mcp-server suite 433/433; tsc -b clean.
Not-tested: public HTTP surface, env-configured HMAC keys, end-to-end
restart against the real relay.
Responders can now pass an optional acceptanceIdempotencyKey to
agent_handshake_accept_invitation so a retryable transport failure no
longer burns the single-use invitation, while role-access handles stay
opaque and live only through the signed access expiry.

Constraint: an absent key preserves the legacy one-shot accept for
older clients; the no-key path is transitional compatibility, not the
durable retry path.
Constraint: present keys must be a lowercase UUIDv4 or canonical
base64url carrying at least 128 bits; malformed values fail at schema
validation before reaching the coordinator.
Constraint: opaque ccra_ handles live only until the signed access
expMs, deduplicated by access digest so retries return the same handle.
Rejected: a fixed wall-clock handle TTL | it could outlive the signed
capability it hides.
Confidence: high
Scope-risk: narrow
Directive: the raw key is validated but never persisted, returned,
or logged; callers generate and retain it with client-native secure
randomness. No new dependencies were added.
Tested: public-server suite 7/7; full @clockchain/mcp-server 437/437;
tsc -b clean; root npm test green.
Not-tested: retry from a real remote MCP client across process
restart; runtime HMAC env wiring lands in the follow-up commit.
Durable keyed accepts now survive HMAC rotation because runtime,
compose, and SSM provisioning carry an active plus optional previous
acceptance HMAC key ring that is distinct from role-access signing
keys, so durable retries keep working through rotation without key
aliasing between purposes.

Constraint: AGENT_HANDSHAKE_ACCEPTANCE_HMAC_ACTIVE is required and
AGENT_HANDSHAKE_ACCEPTANCE_HMAC_PREVIOUS is optional; both are strict
canonical base64 of at least 32 bytes with a valid kid, and every kid
and secret must be distinct across the access and HMAC rings.
Constraint: only a genuine ParameterNotFound marks the previous key
absent; any other SSM fetch corruption fails the deploy closed.
Constraint: the previous acceptance HMAC key is retained until every
responder-access expiry it keyed has passed, per the runbook rotation
order.
Rejected: reusing the role-access ring for idempotency HMACs |
cross-purpose aliasing weakens both rotation boundaries.
Confidence: high
Scope-risk: moderate
Directive: never rotate active and previous acceptance HMAC keys away
in one deploy while referenced claims remain retryable.
Tested: infra suite 34/34 including compose contract, deploy-assets,
and unsafe-rotation rejection tests; @clockchain/mcp-server 437/437;
root npm test green across all workspaces; tsc -b clean.
Not-tested: production SSM parameter provisioning or a live deploy.
@thetangstr
thetangstr changed the base branch from claude/hermes-default-report to main September 17, 2026 02:40
The audited handshake source cannot honestly deploy under the published
v2.1.3 pin: a fresh bundle from audited main differs from the released
helper bytes, and the v2.1.3 tag must never move. Every runtime surface
that names the helper release — pin validator, verified bootstrap,
join-tool literal, signing payloads, and advertised server version —
now derives from one declared V2_HELPER_VERSION, and the deploy
wrapper's jq filter consumes one declared version and derived prefix
instead of embedding both literals. The future manifest digest is not
knowable pre-publication and stays runtime SSM data; only the shape and
version gate live in code.

Constraint: the v2.1.4 tag and GitHub release are deliberately
  unpublished; production SSM still pins v2.1.3, so deploying this
  before publication and the post-publication pin update would fail
  closed by design.
Rejected: keeping a per-surface 2.1.4 literal | the last release
  migration drifted because version, prefix, bootstrap, and fixtures
  each carried their own copy.
Rejected: hardcoding the future manifestDigest | the release workflow
  records SHA-256 of randomized init output, so it cannot be
  precomputed; the pin stays runtime data.
Confidence: high
Scope-risk: narrow — version-gate constants, deploy validation, and
  exact fixtures only; Caddy staging route and durable-accept work
  untouched.
Directive: when bumping the helper release again, change
  V2_HELPER_VERSION in instructions.ts and v2_helper_version in
  compose-up.sh — infra/test/deploy-assets.test.mjs now fails if they
  drift. After publication, update the SSM release pin with the
  published manifestDigest before deploying.
Tested: node --test infra/test/deploy-assets.test.mjs (28/28 incl. new
  drift test); npm test -w @clockchain/mcp-server (437/437); root
  npm run build and npm test (624 workspace + 35 infra); git diff
  --check clean.
Not-tested: acceptance of the real v2.1.4 manifest bytes (asset does
  not exist yet); live deploy against the unpublished release; the
  coordinator join path's wrong-version rejection beyond the existing
  schema/literal gate.
Independent verification of the v2.1.4 gate found one narrow hole: the
coordinator join path had no dedicated negative test, so a regression
that re-accepted an old helperVersion would ship silently. The new
coordinator test drives a real invited session and shows 2.1.3, 2.1.5,
and empty versions are rejected before access authorization while
2.1.4 joins and produces the identity_claim signing request. The
public-server test shows the wire-level zod literal rejects a stale
helperVersion as an isError result with no structured payload, so the
bad input never reaches the coordinator.

Constraint: runtime behavior is frozen at the release candidate; only
  coverage was missing.
Rejected: changing the join gate or schema | the existing literal and
  coordinator check already fail closed; the gap was proof, not
  behavior.
Confidence: high
Scope-risk: narrow — two test files, no runtime changes.
Directive: helperVersion is a fail-closed pin gate, not a negotiation;
  future bumps must keep a stale-version rejection test current.
Tested: node --test on the two touched files (26/26); npm test -w
  @clockchain/mcp-server within root run (438/438); root npm test
  (625 workspace + 35 infra, 0 fail); git diff --check clean.
Not-tested: no new runtime path exists to test; the rejection evidence
  is the assertion itself.
@thetangstr
thetangstr merged commit d418272 into main Sep 17, 2026
2 checks passed
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.

1 participant