From ad371f7876dbc5a95de86e514ca89a6d0e845060 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:25:29 -0700 Subject: [PATCH 01/38] Make fresh identity policy executable from MCP alone Constraint: Fresh Codex and Claude Code clients must not need blockchain constants in the stakeholder prompt. Rejected: Adding Sepolia plumbing to the business prompt | it makes the public MCP contract non-portable and easy to mis-specify. Confidence: high Scope-risk: narrow Directive: Keep the public identity-policy schema conditionally exact whenever supported chains or registries change. Tested: MCP build plus v2 public-server, coordinator, protocol, and v1 compatibility tests. Not-tested: Live cross-client canaries run after deployment. --- .../src/agent-handshake/v2/public-tools.ts | 27 +++++++++++++++---- .../agent-handshake-v2-public-server.test.mjs | 5 ++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 772c87e..7595dc9 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + AGENT_HANDSHAKE_V2_CHAIN_ID, + AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS, +} from "./protocol.js"; + export const V2_PUBLIC_TOOL_NAMES = Object.freeze([ "agent_handshake_invite", "agent_handshake_accept_invitation", @@ -14,11 +19,23 @@ export type V2PublicToolName = typeof V2_PUBLIC_TOOL_NAMES[number]; export type V2PublicInvoke = (name: V2PublicToolName, args: Record) => Promise; const access = z.string().min(80).max(4096); -const identityPolicy = z.object({ - erc8004: z.enum(["required_fresh", "required_existing_or_fresh", "not_required"]), - chainId: z.string().nullable(), - registryAddress: z.string().nullable(), -}).strict(); +const identityPolicy = z.discriminatedUnion("erc8004", [ + z.object({ + erc8004: z.literal("required_fresh"), + chainId: z.literal(AGENT_HANDSHAKE_V2_CHAIN_ID), + registryAddress: z.literal(AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS), + }).strict(), + z.object({ + erc8004: z.literal("required_existing_or_fresh"), + chainId: z.literal(AGENT_HANDSHAKE_V2_CHAIN_ID), + registryAddress: z.literal(AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS), + }).strict(), + z.object({ + erc8004: z.literal("not_required"), + chainId: z.null(), + registryAddress: z.null(), + }).strict(), +]); const definitions = Object.freeze([ { diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index fecb017..1d65636 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -83,6 +83,11 @@ test("the dedicated MCP server exposes exactly seven tools and no prompts or res assert.equal("prompts" in initialized.body.result.capabilities, false); const listed = await rpc(url, "tools/list"); assert.deepEqual(listed.body.result.tools.map((tool) => tool.name), V2_PUBLIC_TOOL_NAMES); + const invite = listed.body.result.tools.find((tool) => tool.name === "agent_handshake_invite"); + const inviteSchema = JSON.stringify(invite.inputSchema); + assert.match(inviteSchema, /eip155:11155111/); + assert.match(inviteSchema, /0x8004a818bfb912233c491871b3d84c89a494bd9e/); + assert.match(inviteSchema, /required_fresh/); assert.equal(listed.body.result.tools.some((tool) => tool.annotations?.requiresUserInteraction === true), false); assert.equal((await rpc(url, "resources/list")).body.error.code, -32601); assert.equal((await rpc(url, "prompts/list")).body.error.code, -32601); From 40967c1692c6601bd41f30f76012cfc8348bb3ef Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:29:12 -0700 Subject: [PATCH 02/38] Make local authority transparent to fresh clients Constraint: Claude Code must distinguish stakeholder-authorized local signing from prompt injection without weakening hash or policy checks. Rejected: Hiding bearer access from the local stakeholder | conflicts with transparent client safety boundaries Confidence: high Scope-risk: narrow Directive: Keep role access out of counterpart channels and logs, while allowing the local stakeholder to audit its use. Tested: npm run build; targeted public initialization test Not-tested: live cross-client canary pending --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 4 ++-- .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 3ed7e81..4dd91cd 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -52,8 +52,8 @@ export function buildV2Instructions(rawPin: unknown): string { return [ `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", - "Use agent_handshake_invite once as the Initiator, copy only the returned Responder invitation to the other person, then keep each returned role access value private and stable for the run.", - `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. Do not write or edit either downloaded file.`, + "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", + `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 1d65636..c5e6117 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -50,6 +50,10 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Node 24/); assert.ok(instructions.includes(V2_VERIFIED_HELPER_BOOTSTRAP)); assert.match(instructions, /compile only those verified bytes in memory/i); + assert.match(instructions, /local bearer credential/i); + assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); + assert.match(instructions, /may inspect the public manifest and helper source before execution/i); + assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); assert.equal(manifest.helper.filename, "clockchain-agent-handshake.cjs"); From 99055ef9e44298eb76fabf6410d5403197d4ba66 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:36:24 -0700 Subject: [PATCH 03/38] Separate protocol mechanics from stakeholder authority Constraint: Fresh Claude Code must receive informed consent from the local stakeholder prompt, not infer authority from MCP-delivered content. Rejected: Treating server instructions as authorization | correctly triggers prompt-injection defenses Confidence: high Scope-risk: narrow Directive: Keep initial stakeholder prompts explicit about expected handshake, pinned helper execution, Sepolia registration, and signing scope. Tested: npm run build; targeted public initialization test Not-tested: live Sonnet canary pending --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 4dd91cd..aa6b468 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -52,6 +52,7 @@ export function buildV2Instructions(rawPin: unknown): string { return [ `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", + "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index c5e6117..789e603 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -53,6 +53,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /local bearer credential/i); assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); assert.match(instructions, /may inspect the public manifest and helper source before execution/i); + assert.match(instructions, /describes mechanics, not stakeholder authorization/i); + assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 15cc94f3c71c9f703ef94df8ec42da15f45b4846 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:57 -0700 Subject: [PATCH 04/38] Make live identity registration executable by fresh agents Return a narrow pinned-helper action at the exact funded registration boundary so stateless clients do not poll until expiry. Constraint: Keep the public surface at seven tools and preserve generic v1 behavior. Rejected: Add a registration MCP tool | It would move local signing authority into the server boundary. Confidence: high Scope-risk: narrow Directive: Keep registration local and express future local steps as explicit machine-actionable coordinator output. Tested: npm test in packages/mcp-server (286/286). Not-tested: Live cross-client canary pending production deployment. --- .../src/agent-handshake/v2/coordinator.ts | 14 ++++- .../src/agent-handshake/v2/instructions.ts | 1 + .../agent-handshake-v2-coordinator.test.mjs | 56 +++++++++++++++++++ .../agent-handshake-v2-public-server.test.mjs | 1 + 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 4c2ac85..ff308e2 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -327,7 +327,19 @@ export function createV2Coordinator(options: { let registration = null; if (current.terms.identityPolicy.erc8004 !== "not_required") { registration = await options.resolveRegistration({ address: current.sessionKeyAddress, fromBlock: current.discovery.sessionOpenedBlock ?? "0" }); - if (!registration) return Object.freeze({ needed: "erc8004_registration", role, sessionId: auth.keyValue.session, stage: "awaiting_identity_registration", identityPolicy: current.terms.identityPolicy }); + if (!registration) return Object.freeze({ + needed: "erc8004_registration", + role, + sessionId: auth.keyValue.session, + stage: "awaiting_identity_registration", + identityPolicy: current.terms.identityPolicy, + localAction: Object.freeze({ + executor: "pinned_helper", + operation: "register", + stateDir: "reuse_exact_absolute_state_dir", + afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", + }), + }); if (current.terms.identityPolicy.erc8004 === "required_fresh" && BigInt(registration.registrationBlock) <= BigInt(current.discovery.sessionOpenedBlock ?? "0")) fail(); } const party = normalizeV2Party({ sessionKeyAddress: current.sessionKeyAddress, policyDigest: current.policyDigest, erc8004: registration }, current.terms.identityPolicy) as JsonObject; diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index aa6b468..ae206dd 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -57,6 +57,7 @@ export function buildV2Instructions(rawPin: unknown): string { `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", + "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", ].join("\n\n"); } diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index a41e6b3..d800132 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -163,3 +163,59 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal((await coordinator.getCertificate({ access: accesses.initiator })).certificate.result.outcome, "VERIFIED"); assert.equal((await coordinator.getCertificate({ access: accesses.responder })).certificate.result.outcome, "VERIFIED"); }); + +test("fresh identity registration is returned as an executable pinned-helper action", async () => { + __resetHandshakeStateStore(); + const key = { kid: "role-2026-08", secret: randomBytes(32) }; + const messages = []; + const address = "0x7564105e977516c53be337314c7e53838967bdac"; + const relay = { + fetchDiscovery: async () => discovery, + getMessages: async () => ({ messages }), + postMessage: async (input) => { + messages.push({ ...input, body: input.body, senderKey: input.senderKey }); + return { ok: true, seq: String(messages.length) }; + }, + }; + const coordinator = createV2Coordinator({ + accessKeys: [key], + activeAccessKey: key, + invitationService: createV2InvitationService({ activeKey: key, verificationKeys: [key], store: createV2InvitationStore(), nowMs: () => nowMs + 1 }), + relay, + stateStore: createHandshakeStateStore({}), + now: () => nowMs + 1, + recoverEip191Address: async () => address, + resolveRegistration: async () => null, + advanceTransitions: async () => [], + }); + + const invited = await coordinator.invite(terms); + const localPolicy = policy("initiator"); + const digest = v2CanonicalRecord(localPolicy).digest; + await coordinator.join({ + access: invited.initiatorAccess, + helperVersion: "2.1.0", + sessionKeyAddress: address, + policyDigest: digest, + }); + await coordinator.submit({ + access: invited.initiatorAccess, + policyDigest: digest, + signatureHex: `0x${"1".repeat(128)}1b`, + }); + messages.push({ kind: "agent_v2_funding_record", role: "host", body: { role: "initiator", address } }); + + assert.deepEqual(await coordinator.next({ access: invited.initiatorAccess }), { + needed: "erc8004_registration", + role: "initiator", + sessionId, + stage: "awaiting_identity_registration", + identityPolicy: terms.identityPolicy, + localAction: { + executor: "pinned_helper", + operation: "register", + stateDir: "reuse_exact_absolute_state_dir", + afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", + }, + }); +}); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 789e603..7bcb5ab 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -55,6 +55,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /may inspect the public manifest and helper source before execution/i); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); + assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 1a3912cf7a4ae092447083078e2a8d683656de1a Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:10:59 -0700 Subject: [PATCH 05/38] Honor the v2 discovery block at identity resolution Accept the canonical decimal session-opened block before converting provider requests to JSON-RPC hex quantities. Constraint: V2 discovery carries decimal block strings while Ethereum JSON-RPC responses use hex quantities. Rejected: Scan from registry creation on every fresh run | It adds unnecessary provider load and weakens the fresh-identity boundary. Confidence: high Scope-risk: narrow Directive: Keep external protocol encodings explicit at RPC boundaries. Tested: npm test in packages/mcp-server (287/287), plus live reproduction against Sepolia. Not-tested: End-to-end fresh-agent canary pending production deployment. --- packages/mcp-server/src/handshake/evm.ts | 5 +++- .../mcp-server/test/handshake-evm.test.mjs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/handshake/evm.ts b/packages/mcp-server/src/handshake/evm.ts index bccbfc2..ebcd443 100644 --- a/packages/mcp-server/src/handshake/evm.ts +++ b/packages/mcp-server/src/handshake/evm.ts @@ -374,7 +374,10 @@ function toHexQuantity(value: bigint): string { } function resolveEarliestBlock(registryAddress: string, fromBlock?: string): bigint { - if (fromBlock !== undefined) return hexQuantity(fromBlock); + if (fromBlock !== undefined) { + if (/^(?:0|[1-9][0-9]*)$/.test(fromBlock)) return BigInt(fromBlock); + return hexQuantity(fromBlock); + } return registryAddress === SEPOLIA_ERC8004_REGISTRY_ADDRESS ? SEPOLIA_ERC8004_REGISTRY_CREATION_BLOCK : 0n; diff --git a/packages/mcp-server/test/handshake-evm.test.mjs b/packages/mcp-server/test/handshake-evm.test.mjs index af27222..78af18d 100644 --- a/packages/mcp-server/test/handshake-evm.test.mjs +++ b/packages/mcp-server/test/handshake-evm.test.mjs @@ -281,6 +281,32 @@ test("resolveOwnedAgentId honors explicit fromBlock over canonical registry defa assert.equal(filter.fromBlock, "0x10"); }); +test("resolveOwnedAgentId accepts the canonical decimal block carried by v2 discovery", async () => { + const calls = []; + const fetchImpl = async (_url, init) => { + const body = JSON.parse(init.body); + calls.push(body); + const result = body.method === "eth_blockNumber" ? "0xaee226" : []; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), + }; + }; + + assert.equal(await resolveOwnedAgentId({ + rpcUrl: RPC_URL, + registryAddress: SEPOLIA_ERC8004_REGISTRY, + address: ADDRESS, + fromBlock: "11461142", + fetchImpl, + }), null); + + const filter = calls.find((call) => call.method === "eth_getLogs").params[0]; + assert.equal(filter.fromBlock, "0xaee216"); + assert.equal(filter.toBlock, "0xaee226"); +}); + test("resolveOwnedAgentId fails clearly when the reverse scan range exceeds the cap", async () => { const { fetchImpl } = rpcFetch((body) => { if (body.method === "eth_blockNumber") return "0x500001"; From 977ecf6a0a1c2c6c204e362f79e1a83b76cc9600 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:19:48 -0700 Subject: [PATCH 06/38] Make signer storage private before first use Give fresh clients one deterministic state-directory bootstrap so the helper can preserve its fail-closed file-permission boundary. Constraint: The workflow must remain plugin-free and keep private keys local. Rejected: Relax private-directory validation | It would weaken signer isolation for the sake of the demo. Confidence: high Scope-risk: narrow Directive: Keep local signer prerequisites explicit in MCP instructions and client allowlists. Tested: Public v2 server tests 3/3; prior full MCP suite 287/287. Not-tested: Fresh production canary pending deployment. --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index ae206dd..c23ff6b 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -56,6 +56,7 @@ export function buildV2Instructions(rawPin: unknown): string { "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, + "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 7bcb5ab..3303a36 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -56,6 +56,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); + assert.match(instructions, /mkdir -m 700 \.\/clockchain-state.*absolute.*\$PWD\/clockchain-state.*every local helper operation/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From cd545512e5916703d49711a70b5ad59c63e52dad Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:24:36 -0700 Subject: [PATCH 07/38] Canonicalize standard EVM addresses at role binding Accept checksummed helper output and lower-case it before every identity, funding, and signature binding. Constraint: The helper emits a standard checksummed address while protocol artifacts require one canonical form. Rejected: Require fresh agents to guess a lowercase retry | Public errors are intentionally opaque and should not force model heuristics. Confidence: high Scope-risk: narrow Directive: Normalize equivalent public encodings once at the coordinator boundary. Tested: npm test in packages/mcp-server (287/287). Not-tested: Fresh production canary pending deployment. --- .../mcp-server/src/agent-handshake/v2/coordinator.ts | 9 +++++---- .../mcp-server/src/agent-handshake/v2/public-tools.ts | 2 +- .../test/agent-handshake-v2-coordinator.test.mjs | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index ff308e2..1a00427 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -58,7 +58,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a- const SHA = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const DECIMAL = /^(?:0|[1-9][0-9]*)$/; -const ADDRESS = /^0x[0-9a-f]{40}$/; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; const SIGNATURE = /^0x[0-9a-f]{130}$/; export class V2CoordinatorError extends Error { @@ -285,19 +285,20 @@ export function createV2Coordinator(options: { async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { if (input.helperVersion !== "2.1.0" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); + const sessionKeyAddress = input.sessionKeyAddress.toLowerCase(); const auth = await authorize(input.access, "agent_handshake_join"); const expectedPolicy = localPolicy(auth.current.terms, auth.verified.payload.role); if (v2CanonicalRecord(expectedPolicy).digest !== input.policyDigest) fail(); - if (auth.current.policyDigest && (auth.current.policyDigest !== input.policyDigest || auth.current.sessionKeyAddress !== input.sessionKeyAddress)) fail(); + if (auth.current.policyDigest && (auth.current.policyDigest !== input.policyDigest || auth.current.sessionKeyAddress !== sessionKeyAddress)) fail(); const claim = normalizeV2IdentityClaim({ schema: "clockchain.agent-handshake-identity-claim/v2", protocol: "clockchain.agent-handshake/v2", sessionId: auth.keyValue.session, repositorySha: auth.current.discovery.repositorySha, - role: auth.verified.payload.role, sessionKeyAddress: input.sessionKeyAddress, + role: auth.verified.payload.role, sessionKeyAddress, policyDigest: input.policyDigest, statementDigest: v2CanonicalRecord(auth.current.terms).digest, externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (current) => merge(current, auth.keyValue, { - policyDigest: input.policyDigest, sessionKeyAddress: input.sessionKeyAddress, + policyDigest: input.policyDigest, sessionKeyAddress, pending: { operation: "identity_claim", payload: claim }, stage: "sign_identity", })); return Object.freeze({ diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 7595dc9..458dad1 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -55,7 +55,7 @@ const definitions = Object.freeze([ description: "Claim one Responder invitation once and receive non-transferable Responder role access.", schema: { invitation: z.string().min(80).max(4096) }, }, - { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.0"), sessionKeyAddress: z.string().regex(/^0x[0-9a-f]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, + { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.0"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, { name: "agent_handshake_status", title: "Read handshake status", description: "Read public progress for this role and session.", schema: { access } }, { name: "agent_handshake_next", title: "Get next handshake operation", description: "Get the next typed local signing or registration operation, or wait safely.", schema: { access } }, { name: "agent_handshake_submit", title: "Submit local signature", description: "Submit only a signature over the exact bytes returned by the coordinator and the unchanged local-policy digest.", schema: { access, policyDigest: z.string().regex(/^[0-9a-f]{64}$/), signatureHex: z.string().regex(/^0x[0-9a-f]{130}$/) } }, diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index d800132..6081ad3 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -169,6 +169,7 @@ test("fresh identity registration is returned as an executable pinned-helper act const key = { kid: "role-2026-08", secret: randomBytes(32) }; const messages = []; const address = "0x7564105e977516c53be337314c7e53838967bdac"; + const presentedAddress = "0x7564105E977516c53be337314c7e53838967bdac"; const relay = { fetchDiscovery: async () => discovery, getMessages: async () => ({ messages }), @@ -195,7 +196,7 @@ test("fresh identity registration is returned as an executable pinned-helper act await coordinator.join({ access: invited.initiatorAccess, helperVersion: "2.1.0", - sessionKeyAddress: address, + sessionKeyAddress: presentedAddress, policyDigest: digest, }); await coordinator.submit({ From c14adbdf4a399273a05b35c233cb401f8adfc221 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:29:28 -0700 Subject: [PATCH 08/38] Prevent invitation replay from masking responder admission Constraint: Responder invitations are one-time bearer capabilities and fresh agents must retain the first successful acceptance result. Rejected: Making invitation acceptance idempotent | Replay rejection is a deliberate security property. Confidence: high Scope-risk: narrow Directive: Keep the one-time invitation invariant and teach clients to treat the first success as authoritative. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs Not-tested: Live production canary pending deployment. --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index c23ff6b..c5a8d37 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -54,6 +54,7 @@ export function buildV2Instructions(rawPin: unknown): string { "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", + "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 3303a36..e3f6cab 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -57,6 +57,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); assert.match(instructions, /mkdir -m 700 \.\/clockchain-state.*absolute.*\$PWD\/clockchain-state.*every local helper operation/is); + assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 512136b6041543135794f85b966ef776ec51b0dc Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:48:24 -0700 Subject: [PATCH 09/38] Keep fresh agents inside the handshake until certification Constraint: Waiting and party-ready states are protocol progress, not terminal success. Rejected: Retrying invitations or treating registration as completion | Invitations are one-time and certification requires both parties plus the checker. Confidence: high Scope-risk: narrow Directive: All nonterminal next responses must carry an explicit continuation signal. Tested: MCP build; v2 coordinator and public-server tests; fresh-client prompt contract. Not-tested: Live production canary pending deployment. --- .../src/agent-handshake/v2/coordinator.ts | 18 ++++++++++-------- .../src/agent-handshake/v2/instructions.ts | 1 + .../agent-handshake-v2-coordinator.test.mjs | 1 + .../agent-handshake-v2-public-server.test.mjs | 2 ++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 1a00427..0563a08 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -60,6 +60,8 @@ const DIGEST = /^[0-9a-f]{64}$/; const DECIMAL = /^(?:0|[1-9][0-9]*)$/; const ADDRESS = /^0x[0-9a-fA-F]{40}$/; const SIGNATURE = /^0x[0-9a-f]{130}$/; +const RETRY_AFTER_MS = 3000; +const NEXT_ACTION = "call_agent_handshake_next_with_unchanged_role_access"; export class V2CoordinatorError extends Error { constructor() { super("Agent handshake coordination failed safely."); this.name = "V2CoordinatorError"; } @@ -323,7 +325,7 @@ export function createV2Coordinator(options: { const entries = (await options.relay.getMessages({ sessionId: auth.keyValue.session })).messages; if (!current.party) { if (current.terms.identityPolicy.erc8004 !== "not_required" && !funded(entries, role, current.sessionKeyAddress)) { - return Object.freeze({ needed: "funding_record", role, sessionId: auth.keyValue.session, stage: "awaiting_funding" }); + return Object.freeze({ needed: "funding_record", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_funding" }); } let registration = null; if (current.terms.identityPolicy.erc8004 !== "not_required") { @@ -338,7 +340,7 @@ export function createV2Coordinator(options: { executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", + afterSuccess: NEXT_ACTION, }), }); if (current.terms.identityPolicy.erc8004 === "required_fresh" && BigInt(registration.registrationBlock) <= BigInt(current.discovery.sessionOpenedBlock ?? "0")) fail(); @@ -346,10 +348,10 @@ export function createV2Coordinator(options: { const party = normalizeV2Party({ sessionKeyAddress: current.sessionKeyAddress, policyDigest: current.policyDigest, erc8004: registration }, current.terms.identityPolicy) as JsonObject; await post(auth.keyValue, "agent_v2_party_ready", party); const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { party, stage: "party_ready" })); - return Object.freeze({ needed: null, role, sessionId: auth.keyValue.session, stage: "party_ready", identity: party }); + return Object.freeze({ needed: null, role, sessionId: auth.keyValue.session, stage: "party_ready", identity: party, nextAction: NEXT_ACTION }); } current = await refresh(auth.keyValue); - if (!current.counterpart) return Object.freeze({ needed: "counterpart_identity", role, sessionId: auth.keyValue.session, stage: "awaiting_counterpart" }); + if (!current.counterpart) return Object.freeze({ needed: "counterpart_identity", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_counterpart" }); const parties = role === "initiator" ? { initiator: current.party, responder: current.counterpart } : { initiator: current.counterpart, responder: current.party }; if (role === "initiator" && !current.proposalEnvelope) { const issuedAtMs = String(now()); @@ -365,7 +367,7 @@ export function createV2Coordinator(options: { return Object.freeze({ stage: "sign_proposal", signingRequest: signRequest(data(updated), role, "proposal", proposal) }); } if (role === "responder" && !current.acceptanceEnvelope) { - if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); + if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); const proposal = normalizeV2Proposal(current.proposalEnvelope.payload) as JsonObject; const acceptance = normalizeV2Acceptance({ schema: "clockchain.agent-handshake-acceptance/v2", protocol: "clockchain.agent-handshake/v2", @@ -379,15 +381,15 @@ export function createV2Coordinator(options: { return Object.freeze({ stage: "sign_acceptance", signingRequest: signRequest(data(updated), role, "acceptance", acceptance) }); } current = await refresh(auth.keyValue); - if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); + if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); const descriptor = normalizeV2Descriptor(current.descriptorEnvelope.descriptor) as JsonObject; const transitions = await options.advanceTransitions({ descriptor, role, existing: current.transitions ?? [] }); if (transitions.length !== 3) { await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { transitions, stage: "awaiting_anchors" })); - return Object.freeze({ needed: "counterpart_transition", role, sessionId: auth.keyValue.session, stage: "awaiting_anchors" }); + return Object.freeze({ needed: "counterpart_transition", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_anchors" }); } if (role === "initiator") await post(auth.keyValue, "agent_v2_anchor_report", { transitions }); - if (current.evidenceUploaded) return Object.freeze({ needed: "certificate", role, sessionId: auth.keyValue.session, stage: "awaiting_certificate" }); + if (current.evidenceUploaded) return Object.freeze({ needed: "certificate", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_certificate" }); const evidence = normalizeV2EvidenceResult({ externalBusinessActionPerformed: false, party: current.party, policyDigest: current.policyDigest, reference: current.terms.reference, repositorySha: current.discovery.repositorySha, role, diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index c5a8d37..b3988ea 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -60,6 +60,7 @@ export function buildV2Instructions(rawPin: unknown): string { "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", + "Every needed or stage response is nonterminal. If it includes a localAction, perform it exactly; otherwise wait for retryAfterMs when returned, then call agent_handshake_next again with the unchanged local role access. A party_ready response includes the same explicit next action. Do not send a final response or exit until the final certificate is locally verified or Clockchain returns an explicit unrecoverable error. Never infer that the other stakeholder stopped from a waiting response.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", ].join("\n\n"); } diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 6081ad3..74543f2 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -118,6 +118,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m messages.push({ kind: "agent_v2_funding_record", role: "host", body: { role, address: addresses[role] } }); const ready = await coordinator.next({ access: accesses[role] }); assert.equal(ready.stage, "party_ready"); + assert.equal(ready.nextAction, "call_agent_handshake_next_with_unchanged_role_access"); } const proposal = await coordinator.next({ access: accesses.initiator }); assert.equal(proposal.signingRequest.operation, "proposal"); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index e3f6cab..ff2b9ee 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -58,6 +58,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); assert.match(instructions, /mkdir -m 700 \.\/clockchain-state.*absolute.*\$PWD\/clockchain-state.*every local helper operation/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); + assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); + assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 9539d798dbaca0dfbc1271d8e048c4d3ab0ec725 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:53:43 -0700 Subject: [PATCH 10/38] Make local policy bytes authoritative from Clockchain Constraint: Fresh agents must not infer the helper policy schema from prose. Rejected: Adding more schema prose to stakeholder prompts | The server can return the exact role-specific policy object it already validates. Confidence: high Scope-risk: narrow Directive: Treat returned localPolicy bytes as immutable input to the helper policy operation. Tested: MCP build; v2 coordinator and public-server tests; fresh-client prompt contract. Not-tested: Live production canary pending deployment. --- packages/mcp-server/src/agent-handshake/v2/coordinator.ts | 4 ++-- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-coordinator.test.mjs | 2 ++ .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 0563a08..96f90ab 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -271,7 +271,7 @@ export function createV2Coordinator(options: { metadata, }); await storeInitial(created.initiatorAccess, metadata, "initiator"); - return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms }); + return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: localPolicy(terms, "initiator") }); }, async acceptInvitation(invitation: string): Promise { @@ -282,7 +282,7 @@ export function createV2Coordinator(options: { claimedAtMs: accepted.claimedAtMs, externalBusinessActionPerformed: false, }); - return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs }); + return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: localPolicy(accepted.metadata.terms as JsonObject, "responder") }); }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index b3988ea..3e7bf92 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -55,6 +55,7 @@ export function buildV2Instructions(rawPin: unknown): string { "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", + "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 74543f2..bef4eee 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -95,7 +95,9 @@ test("two distinct role capabilities drive the complete v2 local-signing state m }); const invited = await coordinator.invite(terms); + assert.deepEqual(invited.localPolicy, policy("initiator")); const accepted = await coordinator.acceptInvitation(invited.responderInvitation); + assert.deepEqual(accepted.localPolicy, policy("responder")); const invitationClaimed = messages.find((message) => message.kind === "agent_v2_invitation_claimed"); assert.equal(invitationClaimed.role, "responder"); assert.equal(invitationClaimed.body.claimedAtMs, String(nowMs + 1)); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index ff2b9ee..a1994ce 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -59,6 +59,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /mkdir -m 700 \.\/clockchain-state.*absolute.*\$PWD\/clockchain-state.*every local helper operation/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); + assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); From cca386e9f0cf1c8138339598403c75d7bc0b5073 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:16:12 -0700 Subject: [PATCH 11/38] Keep transient infrastructure faults out of protocol rejection Constraint: Fresh agents must retry temporary RPC or relay failures without retrying invalid role state or consumed invitations. Rejected: Treating every internal exception as terminal | It made healthy handshakes fail on one transient read. Confidence: high Scope-risk: narrow Directive: Preserve generic errors while carrying an explicit retryable boolean and delay. Tested: MCP build and four public-server tests; fresh-client prompt contract. Not-tested: Live production canary pending deployment. --- .../src/agent-handshake/v2/instructions.ts | 1 + .../src/agent-handshake/v2/public-tools.ts | 17 +++++++++++-- .../agent-handshake-v2-public-server.test.mjs | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 3e7bf92..7faf63c 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -62,6 +62,7 @@ export function buildV2Instructions(rawPin: unknown): string { "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", "Every needed or stage response is nonterminal. If it includes a localAction, perform it exactly; otherwise wait for retryAfterMs when returned, then call agent_handshake_next again with the unchanged local role access. A party_ready response includes the same explicit next action. Do not send a final response or exit until the final certificate is locally verified or Clockchain returns an explicit unrecoverable error. Never infer that the other stakeholder stopped from a waiting response.", + "HANDSHAKE_TEMPORARILY_UNAVAILABLE with retryable: true is not a terminal protocol rejection. Wait for retryAfterMs and retry the same tool with unchanged inputs. Stop on a terminal protocol rejection with retryable: false.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", ].join("\n\n"); } diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 458dad1..ba8305a 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -19,6 +19,12 @@ export type V2PublicToolName = typeof V2_PUBLIC_TOOL_NAMES[number]; export type V2PublicInvoke = (name: V2PublicToolName, args: Record) => Promise; const access = z.string().min(80).max(4096); +const TERMINAL_ERROR_NAMES = new Set([ + "AgentHandshakeV2ValidationError", + "V2CoordinatorError", + "V2InvitationError", + "V2RoleAccessError", +]); const identityPolicy = z.discriminatedUnion("erc8004", [ z.object({ erc8004: z.literal("required_fresh"), @@ -78,8 +84,15 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void try { const result = await invoke(definition.name, args); return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result as Record }; - } catch { - return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: "HANDSHAKE_UNAVAILABLE" }) }] }; + } catch (error) { + const retryable = !TERMINAL_ERROR_NAMES.has((error as Error)?.name) && + (error as Error)?.message !== "rate_limited"; + const body = retryable + ? { error: "HANDSHAKE_TEMPORARILY_UNAVAILABLE", retryable: true, retryAfterMs: 5000 } + : { error: "HANDSHAKE_UNAVAILABLE", retryable: false }; + return retryable + ? { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body } + : { isError: true, content: [{ type: "text", text: JSON.stringify(body) }] }; } }); } diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index a1994ce..9504c9a 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -61,6 +61,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); + assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); @@ -130,3 +131,27 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur await new Promise((resolve) => httpServer.close(resolve)); } }); + +test("public tools distinguish retryable infrastructure failures from terminal protocol rejection", async () => { + for (const candidate of [ + { error: Object.assign(new Error("rpc unavailable"), { name: "RpcRequestError" }), retryable: true }, + { error: Object.assign(new Error("invalid role state"), { name: "V2CoordinatorError" }), retryable: false }, + ]) { + const handler = createV2PublicHttpHandler({ pin, invoke: async () => { throw candidate.error; } }); + const httpServer = createServer((req, res) => handler(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; + try { + const result = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: "a".repeat(80) } }); + const body = JSON.parse(result.body.result.content[0].text); + assert.equal(body.retryable, candidate.retryable); + assert.equal(result.body.result.isError === true, !candidate.retryable); + if (candidate.retryable) { + assert.equal(body.error, "HANDSHAKE_TEMPORARILY_UNAVAILABLE"); + assert.equal(body.retryAfterMs, 5000); + } + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } + } +}); From 65bd1b3b0e416d39e8801e32189c5f3eed700213 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:32:09 -0700 Subject: [PATCH 12/38] Keep pending Clockchain anchors retryable Constraint: Fresh independent agents can observe an anchor before every ledger and chain projection is durably visible.\nRejected: Treat every coordinator error as retryable | protocol-integrity mismatches must remain terminal.\nConfidence: high\nScope-risk: narrow\nDirective: Preserve the distinction between absent propagation state and explicit binding mismatches.\nTested: npm test in packages/mcp-server (289/289)\nNot-tested: Fresh production canary after deployment --- .../src/agent-handshake/v2/coordinator.ts | 18 +++++++++-- .../agent-handshake-v2-coordinator.test.mjs | 30 ++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 96f90ab..c949fe3 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -66,7 +66,11 @@ const NEXT_ACTION = "call_agent_handshake_next_with_unchanged_role_access"; export class V2CoordinatorError extends Error { constructor() { super("Agent handshake coordination failed safely."); this.name = "V2CoordinatorError"; } } +export class V2TransientCoordinatorError extends Error { + constructor() { super("Agent handshake coordination is waiting for durable infrastructure state."); this.name = "V2TransientCoordinatorError"; } +} function fail(): never { throw new V2CoordinatorError(); } +function transient(): never { throw new V2TransientCoordinatorError(); } function exact(value: unknown, keys: readonly string[]): JsonObject { if (value === null || typeof value !== "object" || Array.isArray(value)) fail(); @@ -497,17 +501,25 @@ async function anchorV2(client: any, transition: JsonObject, canWrite: boolean): const ledgerId = String(record.ledgerId ?? ""); if (!UUID.test(ledgerId)) fail(); const ledger = await client.getLedgerEntry(ledgerId); + if ( + !ledger || typeof ledger !== "object" || ledger.blockHeight === undefined || + ledger.ledgerId === undefined || ledger.assetHash === undefined || ledger.assetReferenceId === undefined + ) transient(); const blockHeight = String(ledger.blockHeight ?? ""); if (!DECIMAL.test(blockHeight) || ledger.ledgerId !== ledgerId || ledger.assetHash !== digest || ledger.assetReferenceId !== reference) fail(); const chain = await client.getChainRecord(blockHeight, ledgerId); + if ( + !chain || typeof chain !== "object" || chain.blockHeight === undefined || + chain.assetHash === undefined || chain.assetReferenceId === undefined + ) transient(); if (!chain || chain.assetHash !== digest || chain.assetReferenceId !== reference || String(chain.blockHeight) !== blockHeight) fail(); const block = await client.getBlock(blockHeight); const blockTimeRaw = String(block.blockTime ?? block.madMarzulloTime ?? ""); - if (!blockTimeRaw) fail(); + if (!blockTimeRaw) transient(); return Object.freeze({ blockTimeRaw, digest, message: transition, onChain: Object.freeze({ blockHeight, ledgerId }) }); } -async function advanceRuntimeV2(client: any, input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise { +export async function __advanceRuntimeV2(client: any, input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise { const descriptor = input.descriptor; const sessionDigest = v2CanonicalRecord(descriptor).digest; const base = { @@ -569,6 +581,6 @@ export function createRuntimeV2Coordinator(env: Record advanceRuntimeV2(clockchain, input), + advanceTransitions: (input) => __advanceRuntimeV2(clockchain, input), }); } diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index bef4eee..10d605a 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -4,9 +4,11 @@ import test from "node:test"; import { createHandshakeStateStore, __resetHandshakeStateStore } from "../dist/handshake/state.js"; import { createV2InvitationService, createV2InvitationStore } from "../dist/agent-handshake/v2/invitation-store.js"; -import { createV2Coordinator } from "../dist/agent-handshake/v2/coordinator.js"; +import * as v2CoordinatorModule from "../dist/agent-handshake/v2/coordinator.js"; import { v2CanonicalRecord } from "../dist/agent-handshake/v2/protocol.js"; +const { createV2Coordinator } = v2CoordinatorModule; + const terms = { reference: "NS-1847", statement: "Northstar Logistics and Harbor Supply authorize these two independently controlled agents to communicate about shipment reference NS-1847 for 90 seconds.", @@ -49,6 +51,32 @@ function policy(role) { }; } +test("an unanchored Clockchain ledger response is retryable instead of a terminal protocol rejection", async () => { + assert.equal(typeof v2CoordinatorModule.__advanceRuntimeV2, "function"); + const descriptor = { + agreementExpiresAtMs: String(nowMs + 90_000), + externalBusinessActionPerformed: false, + initiator: { sessionKeyAddress: "0x7564105e977516c53be337314c7e53838967bdac" }, + protocol: "clockchain.agent-handshake/v2", + reference: terms.reference, + responder: { sessionKeyAddress: "0xe1fae9b4fab2f5726677ecfa912d96b0b683e6a9" }, + schema: "clockchain.agent-handshake-descriptor/v2", + statementDigest: v2CanonicalRecord(terms).digest, + }; + const clockchain = { + searchAsset: async () => [], + log: async () => ({ ledgerId: "33333333-4444-4555-8666-777777777770" }), + getLedgerEntry: async () => ({}), + getChainRecord: async () => null, + getBlock: async () => ({}), + }; + + await assert.rejects( + () => v2CoordinatorModule.__advanceRuntimeV2(clockchain, { descriptor, role: "initiator", existing: [] }), + (error) => error?.name === "V2TransientCoordinatorError", + ); +}); + test("two distinct role capabilities drive the complete v2 local-signing state machine", async () => { __resetHandshakeStateStore(); const key = { kid: "role-2026-08", secret: randomBytes(32) }; From 7695c3614e10097faea8e17705a7be3ee46b28b6 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:55:46 -0700 Subject: [PATCH 13/38] Make role access usable without disclosure Constraint: Fresh agents must authenticate each role-scoped MCP call while never sharing bearer access with the counterparty or logs.\nRejected: Remove role-scoped access | distinct principals and role isolation require it.\nConfidence: high\nScope-risk: narrow\nDirective: Describe same-server access arguments as required credential use, not stakeholder disclosure.\nTested: agent-handshake-v2-public-server.test.mjs (4/4)\nNot-tested: Fresh production canary after deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 7faf63c..4bed9f0 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -54,6 +54,7 @@ export function buildV2Instructions(rawPin: unknown): string { "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", + "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 9504c9a..409f8bd 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -62,6 +62,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); + assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 16d305cc2a9669799a04e804b2693626ad4ba455 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:09:02 -0700 Subject: [PATCH 14/38] Keep invitation and role access fields distinct Constraint: The invite response contains one copyable invitation and one private Initiator capability with different allowed tools.\nRejected: Infer the intended field from token shape | both values deliberately share the same signed capability encoding.\nConfidence: high\nScope-risk: narrow\nDirective: Name initiatorAccess, responderInvitation, and responderAccess explicitly in every autonomous-client instruction.\nTested: agent-handshake-v2-public-server.test.mjs (4/4)\nNot-tested: Fresh production canary after deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 2 ++ .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 4bed9f0..443a229 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -54,8 +54,10 @@ export function buildV2Instructions(rawPin: unknown): string { "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", + "After agent_handshake_invite, use initiatorAccess only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for initiatorAccess.", "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", + "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 409f8bd..1c534bc 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -63,6 +63,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); + assert.match(instructions, /initiatorAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); + assert.match(instructions, /responderAccess.*Responder.*original invitation.*never.*access argument/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); From 3cd22ea1a29a944deadc6eff766c642fcbd02a40 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:16:39 -0700 Subject: [PATCH 15/38] Treat pending ledger heights as propagation state Constraint: Clockchain represents a submitted but not-yet-anchored ledger record with blockHeight null.\nRejected: Accept null as an integrity value | only a confirmed decimal height may enter receipt verification.\nConfidence: high\nScope-risk: narrow\nDirective: Retry null projection fields, but keep non-null binding mismatches terminal.\nTested: npm test in packages/mcp-server (289/289)\nNot-tested: Fresh production canary after deployment --- .../mcp-server/src/agent-handshake/v2/coordinator.ts | 10 ++++++---- .../test/agent-handshake-v2-coordinator.test.mjs | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index c949fe3..06a570f 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -502,15 +502,17 @@ async function anchorV2(client: any, transition: JsonObject, canWrite: boolean): if (!UUID.test(ledgerId)) fail(); const ledger = await client.getLedgerEntry(ledgerId); if ( - !ledger || typeof ledger !== "object" || ledger.blockHeight === undefined || - ledger.ledgerId === undefined || ledger.assetHash === undefined || ledger.assetReferenceId === undefined + !ledger || typeof ledger !== "object" || ledger.blockHeight === undefined || ledger.blockHeight === null || + ledger.ledgerId === undefined || ledger.ledgerId === null || ledger.assetHash === undefined || + ledger.assetHash === null || ledger.assetReferenceId === undefined || ledger.assetReferenceId === null ) transient(); const blockHeight = String(ledger.blockHeight ?? ""); if (!DECIMAL.test(blockHeight) || ledger.ledgerId !== ledgerId || ledger.assetHash !== digest || ledger.assetReferenceId !== reference) fail(); const chain = await client.getChainRecord(blockHeight, ledgerId); if ( - !chain || typeof chain !== "object" || chain.blockHeight === undefined || - chain.assetHash === undefined || chain.assetReferenceId === undefined + !chain || typeof chain !== "object" || chain.blockHeight === undefined || chain.blockHeight === null || + chain.assetHash === undefined || chain.assetHash === null || + chain.assetReferenceId === undefined || chain.assetReferenceId === null ) transient(); if (!chain || chain.assetHash !== digest || chain.assetReferenceId !== reference || String(chain.blockHeight) !== blockHeight) fail(); const block = await client.getBlock(blockHeight); diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 10d605a..b11b5fb 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -66,7 +66,12 @@ test("an unanchored Clockchain ledger response is retryable instead of a termina const clockchain = { searchAsset: async () => [], log: async () => ({ ledgerId: "33333333-4444-4555-8666-777777777770" }), - getLedgerEntry: async () => ({}), + getLedgerEntry: async () => ({ + ledgerId: "33333333-4444-4555-8666-777777777770", + blockHeight: null, + assetHash: "pending", + assetReferenceId: "pending", + }), getChainRecord: async () => null, getBlock: async () => ({}), }; From c4da99ac049ffeb78dd77e6a05d02e7b0215deb6 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:49:22 -0700 Subject: [PATCH 16/38] Serve the immutable evidence-signing helper Advance the dedicated handshake endpoint, join schema, verified bootstrap, and deployment validator to helper 2.1.1 so fresh clients can sign the final evidence shape that production actually emits. Constraint: MCP, SSM deployment metadata, and client bootstrap must agree on one exact helper release. Rejected: Continue accepting v2.1.0 | that helper deterministically rejects valid evidence and cannot complete a certificate. Confidence: high Scope-risk: moderate Directive: Deploy only with manifest digest 681f61d4cde2537ec6953b134e8385e6a716c8d889db0f46fd566c10407c9402 and source 8f74f6d953631cbac057426e3540ba73bf607f3b. Tested: MCP build plus 289 tests; 29 infrastructure tests; git diff --check. Not-tested: Live endpoint awaits SSM pin update and EC2 restart. --- infra/clockchain-mcp/compose-up.sh | 2 +- infra/test/deploy-assets.test.mjs | 8 ++++---- packages/mcp-server/src/agent-handshake/v2/coordinator.ts | 4 ++-- .../mcp-server/src/agent-handshake/v2/instructions.ts | 8 ++++---- .../mcp-server/src/agent-handshake/v2/public-server.ts | 3 +-- .../mcp-server/src/agent-handshake/v2/public-tools.ts | 2 +- .../test/agent-handshake-v2-coordinator.test.mjs | 4 ++-- .../test/agent-handshake-v2-public-server.test.mjs | 6 +++--- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/infra/clockchain-mcp/compose-up.sh b/infra/clockchain-mcp/compose-up.sh index 57f7117..7db518b 100755 --- a/infra/clockchain-mcp/compose-up.sh +++ b/infra/clockchain-mcp/compose-up.sh @@ -148,7 +148,7 @@ validate_mcp_runtime_config() { validate_v2_server_config() { local release_filter access_filter active_kid previous_kid - release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.0" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' + release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.1" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' access_filter='type == "object" and (keys | sort) == ["kid","secretBase64"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.secretBase64 | @base64d | length >= 32)' if ! jq -e "$release_filter" >/dev/null 2>&1 <<<"$AGENT_HANDSHAKE_RELEASE_PIN"; then diff --git a/infra/test/deploy-assets.test.mjs b/infra/test/deploy-assets.test.mjs index 740e0b6..1789505 100644 --- a/infra/test/deploy-assets.test.mjs +++ b/infra/test/deploy-assets.test.mjs @@ -39,7 +39,7 @@ const expectedEnv = { CLOCKCHAIN_API_KEY: "api-key-line-1\napi-key-line-2\n", MCP_AUTH_TOKENS: "token-a,token-b\n", MCP_TOKEN_SIGNING_SECRET: "signing-secret\nwith-newline\n", - AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.0","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', + AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.1","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: '{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\n', AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS: '{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\n', }; @@ -187,7 +187,7 @@ case "$name" in /clockchain/mcp/CLOCKCHAIN_API_KEY) value=$'api-key-line-1\\napi-key-line-2\\n' ;; /clockchain/mcp/MCP_AUTH_TOKENS) value=$'token-a,token-b\\n' ;; /clockchain/mcp/MCP_TOKEN_SIGNING_SECRET) value=$'signing-secret\\nwith-newline\\n' ;; - /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.0","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; + /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.1","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE) value=$'{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS) value=$'{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\\n' ;; /clockchain/host/FUNDING_WALLET_JSON) value=$'{"wallet":"line-1\\\\nline-2"}\\n' ;; @@ -297,10 +297,10 @@ async function resolvedComposeConfig() { MCP_AUTH_TOKENS: "dummy-token", MCP_TOKEN_SIGNING_SECRET: "dummy-signing", AGENT_HANDSHAKE_RELEASE_PIN: JSON.stringify({ - version: "2.1.0", + version: "2.1.1", sourceCommit: expectedHandshakeSha, manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/", hostRoots: [{ kid: "root-2026-08", fingerprint: "b".repeat(64) }], }), AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: "dummy-role-active", diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 06a570f..3fb1859 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -140,7 +140,7 @@ function signRequest(current: CoordinatorData, role: V2Role, operation: string, const bytes = canonicalBytes(payload); return Object.freeze({ schema: "clockchain.agent-handshake-signing-request/v1", - helperVersion: "2.1.0", + helperVersion: "2.1.1", operation, role, sessionId: current.discovery.sessionId, @@ -290,7 +290,7 @@ export function createV2Coordinator(options: { }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { - if (input.helperVersion !== "2.1.0" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); + if (input.helperVersion !== "2.1.1" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); const sessionKeyAddress = input.sessionKeyAddress.toLowerCase(); const auth = await authorize(input.access, "agent_handshake_join"); const expectedPolicy = localPolicy(auth.current.terms, auth.verified.payload.role); diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 443a229..def4438 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -1,5 +1,5 @@ export type V2ReleasePin = Readonly<{ - version: "2.1.0"; + version: "2.1.1"; sourceCommit: string; manifestDigest: string; allowedAssetPrefix: string; @@ -9,10 +9,10 @@ export type V2ReleasePin = Readonly<{ const SHA = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const KID = /^[a-z0-9][a-z0-9-]{0,63}$/; -const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/"; +const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/"; const HELPER_FILENAME = "clockchain-agent-handshake.cjs"; -export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.0"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; +export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.1"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; function verifiedBootstrapPrefix(pin: V2ReleasePin): string { return `node --input-type=commonjs --eval '${V2_VERIFIED_HELPER_BOOTSTRAP}' ${pin.manifestDigest} ./manifest.json ./${HELPER_FILENAME}`; @@ -23,7 +23,7 @@ export function validateV2ReleasePin(value: unknown): V2ReleasePin { const item = value as Record; if (Object.keys(item).sort().join(",") !== "allowedAssetPrefix,hostRoots,manifestDigest,sourceCommit,version") throw new Error("Agent handshake release pin is unavailable."); if ( - item.version !== "2.1.0" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || + item.version !== "2.1.1" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || item.allowedAssetPrefix !== PREFIX || !Array.isArray(item.hostRoots) || item.hostRoots.length < 1 || item.hostRoots.length > 2 ) throw new Error("Agent handshake release pin is unavailable."); diff --git a/packages/mcp-server/src/agent-handshake/v2/public-server.ts b/packages/mcp-server/src/agent-handshake/v2/public-server.ts index 94b430f..c240297 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-server.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-server.ts @@ -36,7 +36,7 @@ function limiter(limit: number, windowMs: number, now: () => number) { } export function buildV2PublicServer(options: { pin: V2ReleasePin; invoke: V2PublicInvoke }): McpServer { - const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.0" }, { + const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.1" }, { instructions: buildV2Instructions(options.pin), }); registerV2PublicTools(server, options.invoke); @@ -86,4 +86,3 @@ export function createV2PublicHttpHandler(options: { } }; } - diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index ba8305a..de1d36b 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -61,7 +61,7 @@ const definitions = Object.freeze([ description: "Claim one Responder invitation once and receive non-transferable Responder role access.", schema: { invitation: z.string().min(80).max(4096) }, }, - { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.0"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, + { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.1"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, { name: "agent_handshake_status", title: "Read handshake status", description: "Read public progress for this role and session.", schema: { access } }, { name: "agent_handshake_next", title: "Get next handshake operation", description: "Get the next typed local signing or registration operation, or wait safely.", schema: { access } }, { name: "agent_handshake_submit", title: "Submit local signature", description: "Submit only a signature over the exact bytes returned by the coordinator and the unchanged local-policy digest.", schema: { access, policyDigest: z.string().regex(/^[0-9a-f]{64}$/), signatureHex: z.string().regex(/^0x[0-9a-f]{130}$/) } }, diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index b11b5fb..bcc53ee 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -138,7 +138,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m const accesses = { initiator: invited.initiatorAccess, responder: accepted.responderAccess }; for (const role of ["initiator", "responder"]) { const localPolicy = policy(role); - const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.0", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); + const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.1", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); assert.equal(joined.signingRequest.operation, "identity_claim"); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } @@ -231,7 +231,7 @@ test("fresh identity registration is returned as an executable pinned-helper act const digest = v2CanonicalRecord(localPolicy).digest; await coordinator.join({ access: invited.initiatorAccess, - helperVersion: "2.1.0", + helperVersion: "2.1.1", sessionKeyAddress: presentedAddress, policyDigest: digest, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 1c534bc..b16a586 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -13,10 +13,10 @@ import { import { V2_VERIFIED_HELPER_BOOTSTRAP, buildV2Instructions, buildV2Manifest } from "../dist/agent-handshake/v2/instructions.js"; const pin = { - version: "2.1.0", + version: "2.1.1", sourceCommit: "d".repeat(40), manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/", hostRoots: [ { kid: "root-2026-08", fingerprint: "b".repeat(64) }, { kid: "root-2026-07", fingerprint: "c".repeat(64) }, @@ -39,7 +39,7 @@ test("public initialization leads with the immutable local-authority boundary", const instructions = buildV2Instructions(pin); const first = instructions.slice(0, 512); assert.match(first, /local signing/i); - assert.match(first, /2\.1\.0/); + assert.match(first, /2\.1\.1/); assert.ok(first.includes(pin.manifestDigest)); assert.ok(first.includes(pin.allowedAssetPrefix)); assert.ok(first.includes(pin.hostRoots[0].kid)); From a52a3297abcdb2205497afa8fa15c275b9ac189c Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:02:51 -0700 Subject: [PATCH 17/38] Prevent stale validators from surviving MCP deploys Constraint: systemd executes an out-of-checkout wrapper under /opt/clockchain-mcp.\nRejected: restarting systemd directly after checkout updates | leaves the prior validator installed.\nConfidence: high\nScope-risk: narrow\nDirective: run the deploy-asset installer from the exact checkout before every restart.\nTested: node --test infra/test/deploy-assets.test.mjs; git diff --check\nNot-tested: no additional production restart was needed for this documentation guard. --- infra/clockchain-mcp/RUNBOOK.md | 11 ++++++++--- infra/test/deploy-assets.test.mjs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/infra/clockchain-mcp/RUNBOOK.md b/infra/clockchain-mcp/RUNBOOK.md index 4d97dd0..1f51c2a 100644 --- a/infra/clockchain-mcp/RUNBOOK.md +++ b/infra/clockchain-mcp/RUNBOOK.md @@ -44,9 +44,14 @@ stakeholder capabilities are never stored in SSM. 2. Install the matching Handshake commit in the host checkout, keep the checkout clean, load the active host-root private key from SSM, and record its public fingerprint in the release pin. -3. Install the matching MCP commit, rotate the active/previous role-access key - pair if required, then run `compose-up.sh`. It verifies the exact Handshake - SHA before Docker starts and atomically replaces the private host files. +3. Install the matching MCP commit and rotate the active/previous role-access + key pair if required. From that exact checkout, run + `sudo infra/scripts/install-clockchain-mcp-deploy-assets.sh`. The installer + first refreshes the out-of-checkout `compose-up.sh` and systemd unit, then + restarts the service. The refreshed wrapper verifies the exact Handshake SHA + before Docker starts and atomically replaces the private host files. Never + restart the service directly after changing the checkout: systemd deliberately + executes `/opt/clockchain-mcp/compose-up.sh`, not the copy inside the repo. 4. Deploy Research only after the production MCP manifest reports the same helper digest and host-root ring that Research pins. diff --git a/infra/test/deploy-assets.test.mjs b/infra/test/deploy-assets.test.mjs index 1789505..a977472 100644 --- a/infra/test/deploy-assets.test.mjs +++ b/infra/test/deploy-assets.test.mjs @@ -753,6 +753,20 @@ test("installer enables and restarts the systemd unit", async () => { assert.match(install, /systemctl restart clockchain-mcp\.service/); }); +test("release runbook reinstalls deploy assets before every MCP restart", async () => { + const runbook = await readFile(path.join(deployDir, "RUNBOOK.md"), "utf8"); + assert.match( + runbook, + /infra\/scripts\/install-clockchain-mcp-deploy-assets\.sh/, + "deploys must refresh the out-of-checkout systemd wrapper before restart", + ); + assert.doesNotMatch( + runbook, + /then run `compose-up\.sh`/, + "the copied wrapper must not be invoked without first reinstalling it", + ); +}); + test("provisioning IAM policy is limited to MCP and host SSM prefixes", async () => { const provision = await readFile(path.join(repoRoot, "infra", "scripts", "provision-clockchain-mcp-host.sh"), "utf8"); assert.match(provision, /parameter\/clockchain\/mcp\/\*/); From 4b4d425418e67f6cec90a4188dd128d63b9a9ca8 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:17:45 -0700 Subject: [PATCH 18/38] Make production handshake failures diagnosable without secrets Constraint: public clients must keep receiving the same generic fail-closed errors.\nRejected: returning internal failure details through MCP | creates an unnecessary protocol oracle.\nConfidence: high\nScope-risk: narrow\nDirective: structured diagnostics may include only the tool name and sanitized error class, never inputs or messages.\nTested: full npm test; focused public-server test; git diff --check\nNot-tested: the production log classification requires one new failed or successful canary after deploy. --- .../src/agent-handshake/v2/public-tools.ts | 10 ++++ .../agent-handshake-v2-public-server.test.mjs | 48 ++++++++++++------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index de1d36b..59bdf5b 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -25,6 +25,7 @@ const TERMINAL_ERROR_NAMES = new Set([ "V2InvitationError", "V2RoleAccessError", ]); +const SAFE_ERROR_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/; const identityPolicy = z.discriminatedUnion("erc8004", [ z.object({ erc8004: z.literal("required_fresh"), @@ -85,6 +86,15 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void const result = await invoke(definition.name, args); return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result as Record }; } catch (error) { + const observedName = (error as Error)?.name; + const errorName = typeof observedName === "string" && SAFE_ERROR_NAME.test(observedName) + ? observedName + : "Error"; + console.warn(JSON.stringify({ + event: "agent_handshake_tool_failure", + tool: definition.name, + errorName, + })); const retryable = !TERMINAL_ERROR_NAMES.has((error as Error)?.name) && (error as Error)?.message !== "rate_limited"; const body = retryable diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index b16a586..0187c83 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -136,25 +136,37 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur }); test("public tools distinguish retryable infrastructure failures from terminal protocol rejection", async () => { - for (const candidate of [ - { error: Object.assign(new Error("rpc unavailable"), { name: "RpcRequestError" }), retryable: true }, - { error: Object.assign(new Error("invalid role state"), { name: "V2CoordinatorError" }), retryable: false }, - ]) { - const handler = createV2PublicHttpHandler({ pin, invoke: async () => { throw candidate.error; } }); - const httpServer = createServer((req, res) => handler(req, res)); - await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); - const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; - try { - const result = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: "a".repeat(80) } }); - const body = JSON.parse(result.body.result.content[0].text); - assert.equal(body.retryable, candidate.retryable); - assert.equal(result.body.result.isError === true, !candidate.retryable); - if (candidate.retryable) { - assert.equal(body.error, "HANDSHAKE_TEMPORARILY_UNAVAILABLE"); - assert.equal(body.retryAfterMs, 5000); + const warnings = []; + const originalWarn = console.warn; + console.warn = (value) => warnings.push(value); + try { + for (const candidate of [ + { error: Object.assign(new Error("rpc unavailable"), { name: "RpcRequestError" }), retryable: true }, + { error: Object.assign(new Error("secret invalid role state"), { name: "V2CoordinatorError" }), retryable: false }, + ]) { + const handler = createV2PublicHttpHandler({ pin, invoke: async () => { throw candidate.error; } }); + const httpServer = createServer((req, res) => handler(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; + try { + const result = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: "a".repeat(80) } }); + const body = JSON.parse(result.body.result.content[0].text); + assert.equal(body.retryable, candidate.retryable); + assert.equal(result.body.result.isError === true, !candidate.retryable); + if (candidate.retryable) { + assert.equal(body.error, "HANDSHAKE_TEMPORARILY_UNAVAILABLE"); + assert.equal(body.retryAfterMs, 5000); + } + } finally { + await new Promise((resolve) => httpServer.close(resolve)); } - } finally { - await new Promise((resolve) => httpServer.close(resolve)); } + } finally { + console.warn = originalWarn; } + assert.deepEqual(warnings.map((entry) => JSON.parse(entry)), [ + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "RpcRequestError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "V2CoordinatorError" }, + ]); + assert.equal(warnings.join("\n").includes("secret invalid role state"), false); }); From b00ecf66e04fa6285830cad186fde82a067070a8 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:55:52 -0700 Subject: [PATCH 19/38] Keep headless Claude downloads inside the permission boundary Constraint: Claude Code dontAsk requires the emitted shell command to match an allow rule exactly, and zsh rejects unquoted =https arguments. Rejected: Broaden Bash access | unnecessary and weakens the fresh-agent containment contract Confidence: high Scope-risk: narrow Directive: Keep MCP download instructions byte-aligned with the Claude allowlist. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs; isolated Claude Code sonnet quoted-download probe Not-tested: Full production cross-client handshake pending deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 4 +++- .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index def4438..7283374 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -48,6 +48,8 @@ export function buildV2Instructions(rawPin: unknown): string { const roots = pin.hostRoots.map((root) => `${root.kid}:${root.fingerprint}`).join(","); const manifestUrl = `${pin.allowedAssetPrefix}manifest.json`; const helperUrl = `${pin.allowedAssetPrefix}${HELPER_FILENAME}`; + const manifestDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json '${manifestUrl}'`; + const helperDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./${HELPER_FILENAME} '${helperUrl}'`; const bootstrap = verifiedBootstrapPrefix(pin); return [ `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, @@ -59,7 +61,7 @@ export function buildV2Instructions(rawPin: unknown): string { "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", - `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. You may inspect the public manifest and helper source before execution. Do not modify either downloaded file.`, + `Download the two pinned files by running these exact commands once: ${manifestDownload} ; then ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 0187c83..6b2be64 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -52,7 +52,9 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /compile only those verified bytes in memory/i); assert.match(instructions, /local bearer credential/i); assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); - assert.match(instructions, /may inspect the public manifest and helper source before execution/i); + assert.match(instructions, /inspect the downloaded manifest and helper source before execution/i); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/manifest.json'")); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs'")); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); From fe12d70c7ba00e02e14c882b8c577038094c85b2 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:12:11 -0700 Subject: [PATCH 20/38] Keep Claude helper downloads as separate allowed actions Constraint: Claude Code dontAsk evaluates each emitted Bash shape against the narrow allowlist, and Sonnet followed an MCP semicolon hint by combining downloads. Rejected: Permit compound Bash | unnecessary and expands the local-authority surface Confidence: high Scope-risk: narrow Directive: Describe every allowlisted Bash action as a separate tool call without shell wrappers or separators. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs; live Sonnet trace isolated the denied compound shape Not-tested: Full production cross-client handshake pending deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 3 ++- .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 7283374..b6f5134 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -61,7 +61,8 @@ export function buildV2Instructions(rawPin: unknown): string { "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", - `Download the two pinned files by running these exact commands once: ${manifestDownload} ; then ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, + `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, + `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 6b2be64..3cb0ed1 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -55,6 +55,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /inspect the downloaded manifest and helper source before execution/i); assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/manifest.json'")); assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs'")); + assert.match(instructions, /each command as its own separate Bash tool call.*never prefix, wrap, or combine/is); + assert.equal(instructions.includes(" ; then "), false); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); From bc77189f8989ff616b8950a1a37e91b682f46a3f Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:23:11 -0700 Subject: [PATCH 21/38] Keep every Claude signing action independently allowlisted Constraint: Sonnet optimized the download and state-directory steps into one four-operator Bash call, which dontAsk correctly denied. Rejected: Allow compound shell commands | obscures the local-authority boundary and admits unintended command composition Confidence: high Scope-risk: narrow Directive: Each Bash action in MCP instructions must remain ordered, standalone, and byte-aligned with one allow rule. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs; safe live command-shape trace identified curl plus mkdir in the denied compound Not-tested: Full production cross-client handshake pending deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index b6f5134..10b873e 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -61,6 +61,7 @@ export function buildV2Instructions(rawPin: unknown): string { "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", + "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not create ./clockchain-state until after both pinned files have been downloaded and inspected.", `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 3cb0ed1..0478e07 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -56,6 +56,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/manifest.json'")); assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs'")); assert.match(instructions, /each command as its own separate Bash tool call.*never prefix, wrap, or combine/is); + assert.match(instructions, /every Bash action.*one standalone Bash tool call.*never combine.*do not create.*clockchain-state.*until after.*downloaded.*inspected/is); assert.equal(instructions.includes(" ; then "), false); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); From 2d5f020281ae06275df7f8b41e414b5717246568 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:46:03 -0700 Subject: [PATCH 22/38] Keep canonical terms digests unambiguous to fresh agents Constraint: Fresh clients must validate exact returned terms without mistaking the canonical terms digest for a raw-text hash. Rejected: Letting agents infer the digest scheme | it caused a safe but unnecessary live refusal. Confidence: high Scope-risk: narrow Directive: Keep statementDigest defined as SHA-256 over Clockchain canonical full terms. Tested: MCP build and focused public-server tests, 4/4. Not-tested: Live cross-client canary follows after deployment. --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 10b873e..ea7ffc4 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -60,6 +60,7 @@ export function buildV2Instructions(rawPin: unknown): string { "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", + "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not create ./clockchain-state until after both pinned files have been downloaded and inspected.", `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 0478e07..5f1c8a6 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -65,6 +65,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); + assert.match(instructions, /statementDigest.*sha-256.*canonical.*terms object.*not.*raw statement text/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); From 8561953f99fdc5bb33dcbbc9bcb69265adbb8387 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:06:01 -0700 Subject: [PATCH 23/38] Make every local helper payload executable from MCP Constraint: Fresh clients must complete policy, signing, and certificate verification without repository knowledge or guessed JSON shapes. Rejected: Expanding the stakeholder prompt with CLI mechanics | the MCP should carry executable local actions. Confidence: high Scope-risk: moderate Directive: Every helper payload must remain exact, role-scoped, and free of bearer credentials or private keys. Tested: MCP build plus focused coordinator and public-server suites, 7/7. Not-tested: Live Terra-to-Sonnet canary follows after deployment. --- .../src/agent-handshake/v2/coordinator.ts | 81 +++++++++++++++++-- .../agent-handshake-v2-coordinator.test.mjs | 24 +++++- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 3fb1859..b8657e0 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -171,6 +171,53 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str }); } +function setupLocalAction(policy: JsonObject): JsonObject { + return Object.freeze({ + executor: "pinned_helper", + operations: Object.freeze(["init", "policy", "inspect"]), + payloadEncoding: "base64url_utf8_json", + policyPayload: policy, + stateDir: "new_private_absolute_state_dir", + afterSuccess: "call_agent_handshake_join_with_helper_output", + }); +} + +function signingLocalAction(signingRequest: JsonObject): JsonObject { + return Object.freeze({ + executor: "pinned_helper", + operation: "sign", + payloadEncoding: "base64url_utf8_json", + payload: signingRequest, + stateDir: "reuse_exact_absolute_state_dir", + afterSuccess: "call_agent_handshake_submit_with_helper_output_and_unchanged_policy_digest", + }); +} + +function certificateLocalAction(input: { + certificate: JsonObject; + discovery: JsonObject; + role: V2Role; + sessionId: string; +}): JsonObject { + return Object.freeze({ + executor: "pinned_helper", + operation: "verify-certificate", + payloadEncoding: "base64url_utf8_json", + payload: Object.freeze({ + schema: "clockchain.agent-handshake-certificate-verification/v1", + helperVersion: "2.1.1", + role: input.role, + sessionId: input.sessionId, + repositorySha: input.discovery.repositorySha, + sessionDeadlineMs: input.discovery.sessionDeadlineMs, + certificate: input.certificate, + externalBusinessActionPerformed: false, + }), + stateDir: "reuse_exact_absolute_state_dir", + terminalProof: "use_verified_helper_output_only", + }); +} + function find(entries: readonly JsonObject[], kind: string, role?: string): JsonObject | undefined { return [...entries].reverse().find((entry) => entry?.kind === kind && (role === undefined || entry?.role === role)); } @@ -275,7 +322,8 @@ export function createV2Coordinator(options: { metadata, }); await storeInitial(created.initiatorAccess, metadata, "initiator"); - return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: localPolicy(terms, "initiator") }); + const policy = localPolicy(terms, "initiator") as JsonObject; + return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(policy) }); }, async acceptInvitation(invitation: string): Promise { @@ -286,7 +334,8 @@ export function createV2Coordinator(options: { claimedAtMs: accepted.claimedAtMs, externalBusinessActionPerformed: false, }); - return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: localPolicy(accepted.metadata.terms as JsonObject, "responder") }); + const policy = localPolicy(accepted.metadata.terms as JsonObject, "responder") as JsonObject; + return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(policy) }); }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { @@ -307,11 +356,13 @@ export function createV2Coordinator(options: { policyDigest: input.policyDigest, sessionKeyAddress, pending: { operation: "identity_claim", payload: claim }, stage: "sign_identity", })); + const signingRequest = signRequest(data(updated), auth.verified.payload.role, "identity_claim", claim); return Object.freeze({ role: auth.verified.payload.role, sessionId: auth.keyValue.session, hostSessionKeyCertificate: auth.current.discovery.hostSessionKeyCertificate, repositorySha: auth.current.discovery.repositorySha, sessionDeadlineMs: auth.current.discovery.sessionDeadlineMs, - signingRequest: signRequest(data(updated), auth.verified.payload.role, "identity_claim", claim), + signingRequest, + localAction: signingLocalAction(signingRequest), }); }, @@ -325,7 +376,10 @@ export function createV2Coordinator(options: { let current = await refresh(auth.keyValue); const role = auth.verified.payload.role; if (!current.policyDigest || !current.sessionKeyAddress) fail(); - if (current.pending) return Object.freeze({ stage: current.stage, signingRequest: signRequest(current, role, current.pending.operation, current.pending.payload) }); + if (current.pending) { + const signingRequest = signRequest(current, role, current.pending.operation, current.pending.payload); + return Object.freeze({ stage: current.stage, signingRequest, localAction: signingLocalAction(signingRequest) }); + } const entries = (await options.relay.getMessages({ sessionId: auth.keyValue.session })).messages; if (!current.party) { if (current.terms.identityPolicy.erc8004 !== "not_required" && !funded(entries, role, current.sessionKeyAddress)) { @@ -368,7 +422,8 @@ export function createV2Coordinator(options: { externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "proposal", payload: proposal }, stage: "sign_proposal" })); - return Object.freeze({ stage: "sign_proposal", signingRequest: signRequest(data(updated), role, "proposal", proposal) }); + const signingRequest = signRequest(data(updated), role, "proposal", proposal); + return Object.freeze({ stage: "sign_proposal", signingRequest, localAction: signingLocalAction(signingRequest) }); } if (role === "responder" && !current.acceptanceEnvelope) { if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); @@ -382,7 +437,8 @@ export function createV2Coordinator(options: { externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "acceptance", payload: acceptance }, stage: "sign_acceptance" })); - return Object.freeze({ stage: "sign_acceptance", signingRequest: signRequest(data(updated), role, "acceptance", acceptance) }); + const signingRequest = signRequest(data(updated), role, "acceptance", acceptance); + return Object.freeze({ stage: "sign_acceptance", signingRequest, localAction: signingLocalAction(signingRequest) }); } current = await refresh(auth.keyValue); if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); @@ -402,7 +458,8 @@ export function createV2Coordinator(options: { transitionDigests: transitions.map((entry) => entry.digest), }, current.terms.identityPolicy) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { transitions, pending: { operation: "evidence", payload: evidence }, stage: "sign_evidence" })); - return Object.freeze({ stage: "sign_evidence", signingRequest: signRequest(data(updated), role, "evidence", evidence) }); + const signingRequest = signRequest(data(updated), role, "evidence", evidence); + return Object.freeze({ stage: "sign_evidence", signingRequest, localAction: signingLocalAction(signingRequest) }); }, async submit(input: { access: string; policyDigest: string; signatureHex: string }): Promise { @@ -450,7 +507,15 @@ export function createV2Coordinator(options: { result.parties[auth.verified.payload.role].sessionKeyAddress !== auth.current.sessionKeyAddress ) fail(); await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateVerified: true, stage: "certificate_available" })); - return Object.freeze({ certificate: envelope }); + return Object.freeze({ + certificate: envelope, + localAction: certificateLocalAction({ + certificate: envelope, + discovery: auth.current.discovery, + role: auth.verified.payload.role, + sessionId: auth.keyValue.session, + }), + }); }, async invoke(name: string, args: JsonObject): Promise { diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index bcc53ee..97e5125 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -129,8 +129,17 @@ test("two distinct role capabilities drive the complete v2 local-signing state m const invited = await coordinator.invite(terms); assert.deepEqual(invited.localPolicy, policy("initiator")); + assert.deepEqual(invited.localAction, { + executor: "pinned_helper", + operations: ["init", "policy", "inspect"], + payloadEncoding: "base64url_utf8_json", + policyPayload: policy("initiator"), + stateDir: "new_private_absolute_state_dir", + afterSuccess: "call_agent_handshake_join_with_helper_output", + }); const accepted = await coordinator.acceptInvitation(invited.responderInvitation); assert.deepEqual(accepted.localPolicy, policy("responder")); + assert.deepEqual(accepted.localAction.policyPayload, policy("responder")); const invitationClaimed = messages.find((message) => message.kind === "agent_v2_invitation_claimed"); assert.equal(invitationClaimed.role, "responder"); assert.equal(invitationClaimed.body.claimedAtMs, String(nowMs + 1)); @@ -140,6 +149,8 @@ test("two distinct role capabilities drive the complete v2 local-signing state m const localPolicy = policy(role); const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.1", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); assert.equal(joined.signingRequest.operation, "identity_claim"); + assert.deepEqual(joined.localAction.payload, joined.signingRequest); + assert.equal(joined.localAction.operation, "sign"); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } const identityMessages = messages.filter((message) => message.kind === "agent_v2_identity_claim"); @@ -157,9 +168,11 @@ test("two distinct role capabilities drive the complete v2 local-signing state m } const proposal = await coordinator.next({ access: accesses.initiator }); assert.equal(proposal.signingRequest.operation, "proposal"); + assert.deepEqual(proposal.localAction.payload, proposal.signingRequest); await coordinator.submit({ access: accesses.initiator, policyDigest: v2CanonicalRecord(policy("initiator")).digest, signatureHex: `0x${"2".repeat(128)}1b` }); const acceptance = await coordinator.next({ access: accesses.responder }); assert.equal(acceptance.signingRequest.operation, "acceptance"); + assert.deepEqual(acceptance.localAction.payload, acceptance.signingRequest); await coordinator.submit({ access: accesses.responder, policyDigest: v2CanonicalRecord(policy("responder")).digest, signatureHex: `0x${"3".repeat(128)}1c` }); const proposalPayload = messages.find((message) => message.kind === "agent_v2_proposal").body.proposalEnvelope.payload; @@ -186,6 +199,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m for (const role of ["initiator", "responder"]) { const evidence = await coordinator.next({ access: accesses[role] }); assert.equal(evidence.signingRequest.operation, "evidence"); + assert.deepEqual(evidence.localAction.payload, evidence.signingRequest); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(policy(role)).digest, signatureHex: `0x${"4".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } result = { result: { @@ -196,8 +210,14 @@ test("two distinct role capabilities drive the complete v2 local-signing state m reference: terms.reference, schema: "clockchain.agent-handshake-result/v2", sessionDigest: v2CanonicalRecord(descriptor).digest, sessionId, statementDigest: v2CanonicalRecord(terms).digest, subjectRun: "stakeholder", }, signer: {}, hostSessionKeyCertificate }; - assert.equal((await coordinator.getCertificate({ access: accesses.initiator })).certificate.result.outcome, "VERIFIED"); - assert.equal((await coordinator.getCertificate({ access: accesses.responder })).certificate.result.outcome, "VERIFIED"); + const initiatorCertificate = await coordinator.getCertificate({ access: accesses.initiator }); + const responderCertificate = await coordinator.getCertificate({ access: accesses.responder }); + assert.equal(initiatorCertificate.certificate.result.outcome, "VERIFIED"); + assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); + assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); + assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); + assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); + assert.equal(responderCertificate.localAction.payload.role, "responder"); }); test("fresh identity registration is returned as an executable pinned-helper action", async () => { From 9ff381a0c5849a18e2a0276fe711cef486120757 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:11:48 -0700 Subject: [PATCH 24/38] Keep fresh clients inside their disposable workspace Constraint: Claude native sandbox exposes a shared runtime temp root that must never hold reusable handshake state. Rejected: Reusing or cleaning shared temp paths | would weaken fresh-agent provenance and risk deleting unrelated files. Confidence: high Scope-risk: narrow Directive: Keep manifest and helper asset digests explicit and distinct in all client instructions. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs test/agent-handshake-v2-coordinator.test.mjs; git diff --check Not-tested: live cross-client canary pending this deployment --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 2 ++ .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index ea7ffc4..6cdeb99 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -62,6 +62,8 @@ export function buildV2Instructions(rawPin: unknown): string { "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", + "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory. Stay in that directory. Do not create or switch to another working directory, and never use a shared temp directory such as /tmp, /private/tmp, or /tmp/claude-*. Every ./ path below means the current disposable working directory.", + "DIGEST BOUNDARY: The pinned manifest digest applies only to ./manifest.json. The helper has a separate SHA-256 recorded inside that verified manifest. Never compare the helper file directly with the manifest digest; the verified bootstrap checks both hashes in sequence before compiling the helper bytes.", "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not create ./clockchain-state until after both pinned files have been downloaded and inspected.", `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 5f1c8a6..59ff2cf 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -66,6 +66,9 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); assert.match(instructions, /statementDigest.*sha-256.*canonical.*terms object.*not.*raw statement text/is); + assert.match(instructions, /already.*fresh.*disposable.*working directory.*do not create or switch to another working directory/is); + assert.match(instructions, /never.*shared.*temp.*directory/is); + assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); From 35105ca49bff00932cecb91f45ce150fa8d94eff Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:19:48 -0700 Subject: [PATCH 25/38] Fund fresh identities before local registration Constraint: Fresh ERC-8004 keys begin with no Sepolia gas and must be funded by the host after join. Rejected: Letting clients infer registration timing | Sonnet safely attempted registration before Clockchain knew which address to fund. Confidence: high Scope-risk: narrow Directive: Keep the join then fund then register gate explicit in both instructions and structured local actions. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs test/agent-handshake-v2-coordinator.test.mjs; git diff --check Not-tested: live cross-client canary pending this deployment --- packages/mcp-server/src/agent-handshake/v2/coordinator.ts | 1 + packages/mcp-server/src/agent-handshake/v2/instructions.ts | 1 + packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs | 1 + .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index b8657e0..39d513e 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -178,6 +178,7 @@ function setupLocalAction(policy: JsonObject): JsonObject { payloadEncoding: "base64url_utf8_json", policyPayload: policy, stateDir: "new_private_absolute_state_dir", + registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", afterSuccess: "call_agent_handshake_join_with_helper_output", }); } diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 6cdeb99..1a45b72 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -69,6 +69,7 @@ export function buildV2Instructions(rawPin: unknown): string { `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", + "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", "Every needed or stage response is nonterminal. If it includes a localAction, perform it exactly; otherwise wait for retryAfterMs when returned, then call agent_handshake_next again with the unchanged local role access. A party_ready response includes the same explicit next action. Do not send a final response or exit until the final certificate is locally verified or Clockchain returns an explicit unrecoverable error. Never infer that the other stakeholder stopped from a waiting response.", diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 97e5125..6d22bd5 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -135,6 +135,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m payloadEncoding: "base64url_utf8_json", policyPayload: policy("initiator"), stateDir: "new_private_absolute_state_dir", + registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", afterSuccess: "call_agent_handshake_join_with_helper_output", }); const accepted = await coordinator.acceptInvitation(invited.responderInvitation); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 59ff2cf..18af1e4 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -69,6 +69,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /already.*fresh.*disposable.*working directory.*do not create or switch to another working directory/is); assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); + assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); From 8d4477552e915be25a3abe171b2b58ff8c9475f8 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:27:03 -0700 Subject: [PATCH 26/38] Make every local helper invocation mechanical Constraint: Fresh Codex and Claude Code clients must not infer helper flags or re-encode protocol payloads. Rejected: More prompt prose | the failing clients needed typed executable arguments, not additional narrative. Confidence: high Scope-risk: moderate Directive: Return exact helper argv and encoded payload for every local action; clients may replace only the absolute state-directory placeholder. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs test/agent-handshake-v2-coordinator.test.mjs; git diff --check Not-tested: live cross-client canary pending this deployment --- .../src/agent-handshake/v2/coordinator.ts | 35 +++++++++++++------ .../src/agent-handshake/v2/instructions.ts | 1 + .../agent-handshake-v2-coordinator.test.mjs | 13 +++++++ .../agent-handshake-v2-public-server.test.mjs | 2 ++ 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 39d513e..ecb0ca4 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -171,12 +171,23 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str }); } +const STATE_DIR_PLACEHOLDER = "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"; + +function helperStep(operation: string, payload?: JsonObject): JsonObject { + const argvAfterVerifiedPrefix = [operation, "--state-dir", STATE_DIR_PLACEHOLDER]; + if (payload !== undefined) { + argvAfterVerifiedPrefix.push("--payload-base64url", Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")); + } + return Object.freeze({ operation, argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix) }); +} + function setupLocalAction(policy: JsonObject): JsonObject { return Object.freeze({ executor: "pinned_helper", operations: Object.freeze(["init", "policy", "inspect"]), payloadEncoding: "base64url_utf8_json", policyPayload: policy, + helperSteps: Object.freeze([helperStep("init"), helperStep("policy", policy), helperStep("inspect")]), stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", afterSuccess: "call_agent_handshake_join_with_helper_output", @@ -189,6 +200,7 @@ function signingLocalAction(signingRequest: JsonObject): JsonObject { operation: "sign", payloadEncoding: "base64url_utf8_json", payload: signingRequest, + helperStep: helperStep("sign", signingRequest), stateDir: "reuse_exact_absolute_state_dir", afterSuccess: "call_agent_handshake_submit_with_helper_output_and_unchanged_policy_digest", }); @@ -200,20 +212,22 @@ function certificateLocalAction(input: { role: V2Role; sessionId: string; }): JsonObject { + const payload = Object.freeze({ + schema: "clockchain.agent-handshake-certificate-verification/v1", + helperVersion: "2.1.1", + role: input.role, + sessionId: input.sessionId, + repositorySha: input.discovery.repositorySha, + sessionDeadlineMs: input.discovery.sessionDeadlineMs, + certificate: input.certificate, + externalBusinessActionPerformed: false, + }); return Object.freeze({ executor: "pinned_helper", operation: "verify-certificate", payloadEncoding: "base64url_utf8_json", - payload: Object.freeze({ - schema: "clockchain.agent-handshake-certificate-verification/v1", - helperVersion: "2.1.1", - role: input.role, - sessionId: input.sessionId, - repositorySha: input.discovery.repositorySha, - sessionDeadlineMs: input.discovery.sessionDeadlineMs, - certificate: input.certificate, - externalBusinessActionPerformed: false, - }), + payload, + helperStep: helperStep("verify-certificate", payload), stateDir: "reuse_exact_absolute_state_dir", terminalProof: "use_verified_helper_output_only", }); @@ -399,6 +413,7 @@ export function createV2Coordinator(options: { executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", + helperStep: helperStep("register"), afterSuccess: NEXT_ACTION, }), }); diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 1a45b72..089477b 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -68,6 +68,7 @@ export function buildV2Instructions(rawPin: unknown): string { `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, + "When a localAction includes helperSteps or helperStep, append each argvAfterVerifiedPrefix array to the verified prefix in the exact order returned. Replace only REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR with the one absolute state directory you created. Never reconstruct, edit, or re-encode a returned payload. If an operation's argvAfterVerifiedPrefix does not include --payload-base64url, do not add that flag or any payload.", "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 6d22bd5..efcda8c 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -134,6 +134,11 @@ test("two distinct role capabilities drive the complete v2 local-signing state m operations: ["init", "policy", "inspect"], payloadEncoding: "base64url_utf8_json", policyPayload: policy("initiator"), + helperSteps: [ + { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, + { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")] }, + { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, + ], stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", afterSuccess: "call_agent_handshake_join_with_helper_output", @@ -152,6 +157,10 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(joined.signingRequest.operation, "identity_claim"); assert.deepEqual(joined.localAction.payload, joined.signingRequest); assert.equal(joined.localAction.operation, "sign"); + assert.deepEqual(joined.localAction.helperStep.argvAfterVerifiedPrefix, [ + "sign", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", + Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), + ]); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } const identityMessages = messages.filter((message) => message.kind === "agent_v2_identity_claim"); @@ -216,6 +225,9 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(initiatorCertificate.certificate.result.outcome, "VERIFIED"); assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); + assert.deepEqual(initiatorCertificate.localAction.helperStep.argvAfterVerifiedPrefix.slice(0, 4), [ + "verify-certificate", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", + ]); assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); assert.equal(responderCertificate.localAction.payload.role, "responder"); @@ -273,6 +285,7 @@ test("fresh identity registration is returned as an executable pinned-helper act executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", }, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 18af1e4..528962c 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -70,6 +70,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); + assert.match(instructions, /argvAfterVerifiedPrefix.*exact order.*replace only.*REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR.*never.*re-encode.*payload/is); + assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); From 164cc5e902b73c93ce044ffaa4eccf55eb6ffeca Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:43:17 -0700 Subject: [PATCH 27/38] Keep local signer state stable across client shells Constraint: Codex and Claude Code execute helper actions in separate shell processes where PWD-derived variables are not durable. Rejected: Persisting a shell variable or client-chosen path | both reintroduced inference and cross-call drift. Confidence: high Scope-risk: moderate Directive: Use the exact session-and-role-scoped HOME path and shell suffix returned by each local action. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs test/agent-handshake-v2-coordinator.test.mjs; git diff --check Not-tested: live cross-client canary pending this deployment --- .../src/agent-handshake/v2/coordinator.ts | 38 +++++++++++++------ .../src/agent-handshake/v2/instructions.ts | 6 +-- .../agent-handshake-v2-coordinator.test.mjs | 14 ++++--- .../agent-handshake-v2-public-server.test.mjs | 6 +-- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index ecb0ca4..a0395c4 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -171,23 +171,36 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str }); } -const STATE_DIR_PLACEHOLDER = "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"; +function localStateDir(sessionId: string, role: V2Role): string { + if (!UUID.test(sessionId)) fail(); + return `$HOME/.clockchain/handshakes/${sessionId}/${role}`; +} -function helperStep(operation: string, payload?: JsonObject): JsonObject { - const argvAfterVerifiedPrefix = [operation, "--state-dir", STATE_DIR_PLACEHOLDER]; +function helperStep(operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { + const stateDir = localStateDir(sessionId, role); + const argvAfterVerifiedPrefix = [operation, "--state-dir", stateDir]; if (payload !== undefined) { argvAfterVerifiedPrefix.push("--payload-base64url", Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")); } - return Object.freeze({ operation, argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix) }); + const shellCommandSuffix = argvAfterVerifiedPrefix + .map((value, index) => index === 2 ? `"${value}"` : value) + .join(" "); + return Object.freeze({ operation, argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix), shellCommandSuffix }); } -function setupLocalAction(policy: JsonObject): JsonObject { +function setupLocalAction(policy: JsonObject, sessionId: string, role: V2Role): JsonObject { + const stateDir = localStateDir(sessionId, role); return Object.freeze({ executor: "pinned_helper", operations: Object.freeze(["init", "policy", "inspect"]), payloadEncoding: "base64url_utf8_json", policyPayload: policy, - helperSteps: Object.freeze([helperStep("init"), helperStep("policy", policy), helperStep("inspect")]), + stateDirectoryCommand: `mkdir -p -m 700 "${stateDir}"`, + helperSteps: Object.freeze([ + helperStep("init", sessionId, role), + helperStep("policy", sessionId, role, policy), + helperStep("inspect", sessionId, role), + ]), stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", afterSuccess: "call_agent_handshake_join_with_helper_output", @@ -195,12 +208,14 @@ function setupLocalAction(policy: JsonObject): JsonObject { } function signingLocalAction(signingRequest: JsonObject): JsonObject { + const role = signingRequest.role as V2Role; + const sessionId = signingRequest.sessionId as string; return Object.freeze({ executor: "pinned_helper", operation: "sign", payloadEncoding: "base64url_utf8_json", payload: signingRequest, - helperStep: helperStep("sign", signingRequest), + helperStep: helperStep("sign", sessionId, role, signingRequest), stateDir: "reuse_exact_absolute_state_dir", afterSuccess: "call_agent_handshake_submit_with_helper_output_and_unchanged_policy_digest", }); @@ -227,7 +242,7 @@ function certificateLocalAction(input: { operation: "verify-certificate", payloadEncoding: "base64url_utf8_json", payload, - helperStep: helperStep("verify-certificate", payload), + helperStep: helperStep("verify-certificate", input.sessionId, input.role, payload), stateDir: "reuse_exact_absolute_state_dir", terminalProof: "use_verified_helper_output_only", }); @@ -338,7 +353,7 @@ export function createV2Coordinator(options: { }); await storeInitial(created.initiatorAccess, metadata, "initiator"); const policy = localPolicy(terms, "initiator") as JsonObject; - return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(policy) }); + return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(policy, found.sessionId, "initiator") }); }, async acceptInvitation(invitation: string): Promise { @@ -350,7 +365,8 @@ export function createV2Coordinator(options: { externalBusinessActionPerformed: false, }); const policy = localPolicy(accepted.metadata.terms as JsonObject, "responder") as JsonObject; - return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(policy) }); + const sessionId = (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId as string; + return Object.freeze({ responderAccess: accepted.responderAccess, sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(policy, sessionId, "responder") }); }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { @@ -413,7 +429,7 @@ export function createV2Coordinator(options: { executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: helperStep("register"), + helperStep: helperStep("register", auth.keyValue.session, role), afterSuccess: NEXT_ACTION, }), }); diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 089477b..412a1d0 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -64,12 +64,12 @@ export function buildV2Instructions(rawPin: unknown): string { "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory. Stay in that directory. Do not create or switch to another working directory, and never use a shared temp directory such as /tmp, /private/tmp, or /tmp/claude-*. Every ./ path below means the current disposable working directory.", "DIGEST BOUNDARY: The pinned manifest digest applies only to ./manifest.json. The helper has a separate SHA-256 recorded inside that verified manifest. Never compare the helper file directly with the manifest digest; the verified bootstrap checks both hashes in sequence before compiling the helper bytes.", - "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not create ./clockchain-state until after both pinned files have been downloaded and inspected.", + "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not run a returned stateDirectoryCommand until after both pinned files have been downloaded and inspected.", `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, - "When a localAction includes helperSteps or helperStep, append each argvAfterVerifiedPrefix array to the verified prefix in the exact order returned. Replace only REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR with the one absolute state directory you created. Never reconstruct, edit, or re-encode a returned payload. If an operation's argvAfterVerifiedPrefix does not include --payload-base64url, do not add that flag or any payload.", - "Before helper init, run mkdir -m 700 ./clockchain-state exactly once. Use the absolute $PWD/clockchain-state path as --state-dir for every local helper operation in this handshake. Reusing a public or default-permission directory must fail closed.", + "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory under this client's stable $HOME. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", + "Use only the exact session-scoped $HOME path returned by Clockchain for every local helper operation in this handshake. Do not assign it to a shell variable, replace it with $PWD, create another state directory, or reuse state from another session.", "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index efcda8c..5e21f7f 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -128,16 +128,18 @@ test("two distinct role capabilities drive the complete v2 local-signing state m }); const invited = await coordinator.invite(terms); + const initiatorStateDir = `$HOME/.clockchain/handshakes/${sessionId}/initiator`; assert.deepEqual(invited.localPolicy, policy("initiator")); assert.deepEqual(invited.localAction, { executor: "pinned_helper", operations: ["init", "policy", "inspect"], payloadEncoding: "base64url_utf8_json", policyPayload: policy("initiator"), + stateDirectoryCommand: `mkdir -p -m 700 "$HOME/.clockchain/handshakes/${sessionId}/initiator"`, helperSteps: [ - { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, - { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")] }, - { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, + { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, + { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, + { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", initiatorStateDir], shellCommandSuffix: `inspect --state-dir "${initiatorStateDir}"` }, ], stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", @@ -158,7 +160,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.deepEqual(joined.localAction.payload, joined.signingRequest); assert.equal(joined.localAction.operation, "sign"); assert.deepEqual(joined.localAction.helperStep.argvAfterVerifiedPrefix, [ - "sign", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", + "sign", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), ]); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); @@ -226,7 +228,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); assert.deepEqual(initiatorCertificate.localAction.helperStep.argvAfterVerifiedPrefix.slice(0, 4), [ - "verify-certificate", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR", "--payload-base64url", + "verify-certificate", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", ]); assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); @@ -285,7 +287,7 @@ test("fresh identity registration is returned as an executable pinned-helper act executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", "REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR"] }, + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$HOME/.clockchain/handshakes/${sessionId}/initiator"` }, afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", }, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 528962c..a577149 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -56,12 +56,12 @@ test("public initialization leads with the immutable local-authority boundary", assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/manifest.json'")); assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs'")); assert.match(instructions, /each command as its own separate Bash tool call.*never prefix, wrap, or combine/is); - assert.match(instructions, /every Bash action.*one standalone Bash tool call.*never combine.*do not create.*clockchain-state.*until after.*downloaded.*inspected/is); + assert.match(instructions, /every Bash action.*one standalone Bash tool call.*never combine.*do not run.*stateDirectoryCommand.*until after.*downloaded.*inspected/is); assert.equal(instructions.includes(" ; then "), false); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); - assert.match(instructions, /mkdir -m 700 \.\/clockchain-state.*absolute.*\$PWD\/clockchain-state.*every local helper operation/is); + assert.match(instructions, /session-scoped \$HOME path.*every local helper operation.*do not assign.*shell variable.*replace.*\$PWD/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); @@ -70,7 +70,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); - assert.match(instructions, /argvAfterVerifiedPrefix.*exact order.*replace only.*REPLACE_WITH_EXACT_ABSOLUTE_STATE_DIR.*never.*re-encode.*payload/is); + assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*\$HOME.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); From d41edc3b7a48b1a74cff92a310d1a80f6814207b Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:46:54 -0700 Subject: [PATCH 28/38] Confine session signer state to the client workspace Constraint: Codex workspace-write intentionally denies the client's home directory. Rejected: Expanding client home permissions | the signer needs only its fresh workspace and broader access weakens the demo boundary. Confidence: high Scope-risk: narrow Directive: Keep the exact session-scoped state path under PWD and preserve typed helper suffixes. Tested: npm run build; node --test test/agent-handshake-v2-public-server.test.mjs test/agent-handshake-v2-coordinator.test.mjs; git diff --check Not-tested: live cross-client canary pending this deployment --- .../mcp-server/src/agent-handshake/v2/coordinator.ts | 2 +- .../mcp-server/src/agent-handshake/v2/instructions.ts | 4 ++-- .../test/agent-handshake-v2-coordinator.test.mjs | 10 +++++----- .../test/agent-handshake-v2-public-server.test.mjs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index a0395c4..9032e22 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -173,7 +173,7 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str function localStateDir(sessionId: string, role: V2Role): string { if (!UUID.test(sessionId)) fail(); - return `$HOME/.clockchain/handshakes/${sessionId}/${role}`; + return `$PWD/.clockchain/handshakes/${sessionId}/${role}`; } function helperStep(operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 412a1d0..942a5c0 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -68,8 +68,8 @@ export function buildV2Instructions(rawPin: unknown): string { `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, - "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory under this client's stable $HOME. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", - "Use only the exact session-scoped $HOME path returned by Clockchain for every local helper operation in this handshake. Do not assign it to a shell variable, replace it with $PWD, create another state directory, or reuse state from another session.", + "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory inside this client's fresh disposable working directory. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", + "Use only the exact session-scoped $PWD path returned by Clockchain for every local helper operation in this handshake. Do not assign it to a shell variable, replace it with $HOME or a temp path, create another state directory, or reuse state from another session.", "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 5e21f7f..4ed64a1 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -128,14 +128,14 @@ test("two distinct role capabilities drive the complete v2 local-signing state m }); const invited = await coordinator.invite(terms); - const initiatorStateDir = `$HOME/.clockchain/handshakes/${sessionId}/initiator`; + const initiatorStateDir = `$PWD/.clockchain/handshakes/${sessionId}/initiator`; assert.deepEqual(invited.localPolicy, policy("initiator")); assert.deepEqual(invited.localAction, { executor: "pinned_helper", operations: ["init", "policy", "inspect"], payloadEncoding: "base64url_utf8_json", policyPayload: policy("initiator"), - stateDirectoryCommand: `mkdir -p -m 700 "$HOME/.clockchain/handshakes/${sessionId}/initiator"`, + stateDirectoryCommand: `mkdir -p -m 700 "$PWD/.clockchain/handshakes/${sessionId}/initiator"`, helperSteps: [ { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, @@ -160,7 +160,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.deepEqual(joined.localAction.payload, joined.signingRequest); assert.equal(joined.localAction.operation, "sign"); assert.deepEqual(joined.localAction.helperStep.argvAfterVerifiedPrefix, [ - "sign", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", + "sign", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), ]); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); @@ -228,7 +228,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); assert.deepEqual(initiatorCertificate.localAction.helperStep.argvAfterVerifiedPrefix.slice(0, 4), [ - "verify-certificate", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", + "verify-certificate", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", ]); assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); @@ -287,7 +287,7 @@ test("fresh identity registration is returned as an executable pinned-helper act executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$HOME/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$HOME/.clockchain/handshakes/${sessionId}/initiator"` }, + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$PWD/.clockchain/handshakes/${sessionId}/initiator"` }, afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", }, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index a577149..b0f90df 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -61,7 +61,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); - assert.match(instructions, /session-scoped \$HOME path.*every local helper operation.*do not assign.*shell variable.*replace.*\$PWD/is); + assert.match(instructions, /session-scoped \$PWD path.*every local helper operation.*do not assign.*shell variable.*replace.*\$HOME.*temp path/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); @@ -70,7 +70,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); - assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*\$HOME.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); + assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*fresh disposable working directory.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); From f03b1239b5eae51311fc6d32ec227ad12978e306 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:15:28 -0700 Subject: [PATCH 29/38] Serve the corrected fresh-agent authority contract Fresh Codex and Claude Code sessions need one isolated session temp root and helper 2.1.2 so pre-registration inspection can precede host funding without exposing or reusing signer state. Constraint: The public endpoint remains exactly seven tools and private keys never cross MCP. Rejected: Shared PWD or host temp state | Claude Code redirects sandboxed temporary work and can expose prior per-user runtime state. Confidence: high Scope-risk: moderate Directive: Keep the MCP release pin, helper schema version, bootstrap URL, and deployment validator atomic. Tested: MCP build and 7 focused v2 tests; 24 deployment asset tests; diff check. Not-tested: production canary awaits SSM pin update and deployment. --- infra/clockchain-mcp/compose-up.sh | 2 +- infra/test/deploy-assets.test.mjs | 8 ++++---- .../src/agent-handshake/v2/coordinator.ts | 8 ++++---- .../src/agent-handshake/v2/instructions.ts | 14 +++++++------- .../src/agent-handshake/v2/public-server.ts | 2 +- .../src/agent-handshake/v2/public-tools.ts | 2 +- .../test/agent-handshake-v2-coordinator.test.mjs | 14 +++++++------- .../test/agent-handshake-v2-public-server.test.mjs | 14 +++++++------- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/infra/clockchain-mcp/compose-up.sh b/infra/clockchain-mcp/compose-up.sh index 7db518b..da1ae29 100755 --- a/infra/clockchain-mcp/compose-up.sh +++ b/infra/clockchain-mcp/compose-up.sh @@ -148,7 +148,7 @@ validate_mcp_runtime_config() { validate_v2_server_config() { local release_filter access_filter active_kid previous_kid - release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.1" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' + release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.2" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' access_filter='type == "object" and (keys | sort) == ["kid","secretBase64"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.secretBase64 | @base64d | length >= 32)' if ! jq -e "$release_filter" >/dev/null 2>&1 <<<"$AGENT_HANDSHAKE_RELEASE_PIN"; then diff --git a/infra/test/deploy-assets.test.mjs b/infra/test/deploy-assets.test.mjs index a977472..7f73d21 100644 --- a/infra/test/deploy-assets.test.mjs +++ b/infra/test/deploy-assets.test.mjs @@ -39,7 +39,7 @@ const expectedEnv = { CLOCKCHAIN_API_KEY: "api-key-line-1\napi-key-line-2\n", MCP_AUTH_TOKENS: "token-a,token-b\n", MCP_TOKEN_SIGNING_SECRET: "signing-secret\nwith-newline\n", - AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.1","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', + AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.2","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: '{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\n', AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS: '{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\n', }; @@ -187,7 +187,7 @@ case "$name" in /clockchain/mcp/CLOCKCHAIN_API_KEY) value=$'api-key-line-1\\napi-key-line-2\\n' ;; /clockchain/mcp/MCP_AUTH_TOKENS) value=$'token-a,token-b\\n' ;; /clockchain/mcp/MCP_TOKEN_SIGNING_SECRET) value=$'signing-secret\\nwith-newline\\n' ;; - /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.1","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; + /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.2","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE) value=$'{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS) value=$'{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\\n' ;; /clockchain/host/FUNDING_WALLET_JSON) value=$'{"wallet":"line-1\\\\nline-2"}\\n' ;; @@ -297,10 +297,10 @@ async function resolvedComposeConfig() { MCP_AUTH_TOKENS: "dummy-token", MCP_TOKEN_SIGNING_SECRET: "dummy-signing", AGENT_HANDSHAKE_RELEASE_PIN: JSON.stringify({ - version: "2.1.1", + version: "2.1.2", sourceCommit: expectedHandshakeSha, manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/", hostRoots: [{ kid: "root-2026-08", fingerprint: "b".repeat(64) }], }), AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: "dummy-role-active", diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 9032e22..77237b1 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -140,7 +140,7 @@ function signRequest(current: CoordinatorData, role: V2Role, operation: string, const bytes = canonicalBytes(payload); return Object.freeze({ schema: "clockchain.agent-handshake-signing-request/v1", - helperVersion: "2.1.1", + helperVersion: "2.1.2", operation, role, sessionId: current.discovery.sessionId, @@ -173,7 +173,7 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str function localStateDir(sessionId: string, role: V2Role): string { if (!UUID.test(sessionId)) fail(); - return `$PWD/.clockchain/handshakes/${sessionId}/${role}`; + return `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`; } function helperStep(operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { @@ -229,7 +229,7 @@ function certificateLocalAction(input: { }): JsonObject { const payload = Object.freeze({ schema: "clockchain.agent-handshake-certificate-verification/v1", - helperVersion: "2.1.1", + helperVersion: "2.1.2", role: input.role, sessionId: input.sessionId, repositorySha: input.discovery.repositorySha, @@ -370,7 +370,7 @@ export function createV2Coordinator(options: { }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { - if (input.helperVersion !== "2.1.1" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); + if (input.helperVersion !== "2.1.2" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); const sessionKeyAddress = input.sessionKeyAddress.toLowerCase(); const auth = await authorize(input.access, "agent_handshake_join"); const expectedPolicy = localPolicy(auth.current.terms, auth.verified.payload.role); diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 942a5c0..77f70f9 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -1,5 +1,5 @@ export type V2ReleasePin = Readonly<{ - version: "2.1.1"; + version: "2.1.2"; sourceCommit: string; manifestDigest: string; allowedAssetPrefix: string; @@ -9,10 +9,10 @@ export type V2ReleasePin = Readonly<{ const SHA = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const KID = /^[a-z0-9][a-z0-9-]{0,63}$/; -const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/"; +const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/"; const HELPER_FILENAME = "clockchain-agent-handshake.cjs"; -export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.1"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; +export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.2"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; function verifiedBootstrapPrefix(pin: V2ReleasePin): string { return `node --input-type=commonjs --eval '${V2_VERIFIED_HELPER_BOOTSTRAP}' ${pin.manifestDigest} ./manifest.json ./${HELPER_FILENAME}`; @@ -23,7 +23,7 @@ export function validateV2ReleasePin(value: unknown): V2ReleasePin { const item = value as Record; if (Object.keys(item).sort().join(",") !== "allowedAssetPrefix,hostRoots,manifestDigest,sourceCommit,version") throw new Error("Agent handshake release pin is unavailable."); if ( - item.version !== "2.1.1" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || + item.version !== "2.1.2" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || item.allowedAssetPrefix !== PREFIX || !Array.isArray(item.hostRoots) || item.hostRoots.length < 1 || item.hostRoots.length > 2 ) throw new Error("Agent handshake release pin is unavailable."); @@ -62,14 +62,14 @@ export function buildV2Instructions(rawPin: unknown): string { "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", - "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory. Stay in that directory. Do not create or switch to another working directory, and never use a shared temp directory such as /tmp, /private/tmp, or /tmp/claude-*. Every ./ path below means the current disposable working directory.", + "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory and assigned a fresh session-scoped $TMPDIR. Stay in the working directory. Do not create or switch to another working directory, and never substitute /tmp, /private/tmp, /tmp/claude-*, or any other shared path. Every ./ path below means the current disposable working directory; every $TMPDIR path means the current isolated client session temp root.", "DIGEST BOUNDARY: The pinned manifest digest applies only to ./manifest.json. The helper has a separate SHA-256 recorded inside that verified manifest. Never compare the helper file directly with the manifest digest; the verified bootstrap checks both hashes in sequence before compiling the helper bytes.", "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not run a returned stateDirectoryCommand until after both pinned files have been downloaded and inspected.", `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, - "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory inside this client's fresh disposable working directory. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", - "Use only the exact session-scoped $PWD path returned by Clockchain for every local helper operation in this handshake. Do not assign it to a shell variable, replace it with $HOME or a temp path, create another state directory, or reuse state from another session.", + "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory beneath this client's isolated $TMPDIR. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", + "Use only the exact session-scoped $TMPDIR path returned by Clockchain for every local helper operation in this handshake. Do not assign it to another shell variable, replace it with $HOME or $PWD, create another state directory, or reuse state from another session.", "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", diff --git a/packages/mcp-server/src/agent-handshake/v2/public-server.ts b/packages/mcp-server/src/agent-handshake/v2/public-server.ts index c240297..e76ea38 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-server.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-server.ts @@ -36,7 +36,7 @@ function limiter(limit: number, windowMs: number, now: () => number) { } export function buildV2PublicServer(options: { pin: V2ReleasePin; invoke: V2PublicInvoke }): McpServer { - const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.1" }, { + const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.2" }, { instructions: buildV2Instructions(options.pin), }); registerV2PublicTools(server, options.invoke); diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 59bdf5b..9cbdf58 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -62,7 +62,7 @@ const definitions = Object.freeze([ description: "Claim one Responder invitation once and receive non-transferable Responder role access.", schema: { invitation: z.string().min(80).max(4096) }, }, - { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.1"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, + { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.2"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, { name: "agent_handshake_status", title: "Read handshake status", description: "Read public progress for this role and session.", schema: { access } }, { name: "agent_handshake_next", title: "Get next handshake operation", description: "Get the next typed local signing or registration operation, or wait safely.", schema: { access } }, { name: "agent_handshake_submit", title: "Submit local signature", description: "Submit only a signature over the exact bytes returned by the coordinator and the unchanged local-policy digest.", schema: { access, policyDigest: z.string().regex(/^[0-9a-f]{64}$/), signatureHex: z.string().regex(/^0x[0-9a-f]{130}$/) } }, diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 4ed64a1..f6fcf00 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -128,14 +128,14 @@ test("two distinct role capabilities drive the complete v2 local-signing state m }); const invited = await coordinator.invite(terms); - const initiatorStateDir = `$PWD/.clockchain/handshakes/${sessionId}/initiator`; + const initiatorStateDir = `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`; assert.deepEqual(invited.localPolicy, policy("initiator")); assert.deepEqual(invited.localAction, { executor: "pinned_helper", operations: ["init", "policy", "inspect"], payloadEncoding: "base64url_utf8_json", policyPayload: policy("initiator"), - stateDirectoryCommand: `mkdir -p -m 700 "$PWD/.clockchain/handshakes/${sessionId}/initiator"`, + stateDirectoryCommand: `mkdir -p -m 700 "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"`, helperSteps: [ { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, @@ -155,12 +155,12 @@ test("two distinct role capabilities drive the complete v2 local-signing state m const accesses = { initiator: invited.initiatorAccess, responder: accepted.responderAccess }; for (const role of ["initiator", "responder"]) { const localPolicy = policy(role); - const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.1", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); + const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.2", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); assert.equal(joined.signingRequest.operation, "identity_claim"); assert.deepEqual(joined.localAction.payload, joined.signingRequest); assert.equal(joined.localAction.operation, "sign"); assert.deepEqual(joined.localAction.helperStep.argvAfterVerifiedPrefix, [ - "sign", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", + "sign", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), ]); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); @@ -228,7 +228,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); assert.deepEqual(initiatorCertificate.localAction.helperStep.argvAfterVerifiedPrefix.slice(0, 4), [ - "verify-certificate", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", + "verify-certificate", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", ]); assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); @@ -266,7 +266,7 @@ test("fresh identity registration is returned as an executable pinned-helper act const digest = v2CanonicalRecord(localPolicy).digest; await coordinator.join({ access: invited.initiatorAccess, - helperVersion: "2.1.1", + helperVersion: "2.1.2", sessionKeyAddress: presentedAddress, policyDigest: digest, }); @@ -287,7 +287,7 @@ test("fresh identity registration is returned as an executable pinned-helper act executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$PWD/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$PWD/.clockchain/handshakes/${sessionId}/initiator"` }, + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"` }, afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", }, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index b0f90df..a0a9a78 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -13,10 +13,10 @@ import { import { V2_VERIFIED_HELPER_BOOTSTRAP, buildV2Instructions, buildV2Manifest } from "../dist/agent-handshake/v2/instructions.js"; const pin = { - version: "2.1.1", + version: "2.1.2", sourceCommit: "d".repeat(40), manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/", hostRoots: [ { kid: "root-2026-08", fingerprint: "b".repeat(64) }, { kid: "root-2026-07", fingerprint: "c".repeat(64) }, @@ -39,7 +39,7 @@ test("public initialization leads with the immutable local-authority boundary", const instructions = buildV2Instructions(pin); const first = instructions.slice(0, 512); assert.match(first, /local signing/i); - assert.match(first, /2\.1\.1/); + assert.match(first, /2\.1\.2/); assert.ok(first.includes(pin.manifestDigest)); assert.ok(first.includes(pin.allowedAssetPrefix)); assert.ok(first.includes(pin.hostRoots[0].kid)); @@ -53,15 +53,15 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /local bearer credential/i); assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); assert.match(instructions, /inspect the downloaded manifest and helper source before execution/i); - assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/manifest.json'")); - assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.1/clockchain-agent-handshake.cjs'")); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/manifest.json'")); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs'")); assert.match(instructions, /each command as its own separate Bash tool call.*never prefix, wrap, or combine/is); assert.match(instructions, /every Bash action.*one standalone Bash tool call.*never combine.*do not run.*stateDirectoryCommand.*until after.*downloaded.*inspected/is); assert.equal(instructions.includes(" ; then "), false); assert.match(instructions, /describes mechanics, not stakeholder authorization/i); assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); - assert.match(instructions, /session-scoped \$PWD path.*every local helper operation.*do not assign.*shell variable.*replace.*\$HOME.*temp path/is); + assert.match(instructions, /session-scoped \$TMPDIR path.*every local helper operation.*do not assign.*shell variable.*replace.*\$HOME.*\$PWD/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); @@ -70,7 +70,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); - assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*fresh disposable working directory.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); + assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*isolated \$TMPDIR.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); From 329cfcea7ec88cb10480cefcfc2d27fe7c797da3 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:47:21 -0700 Subject: [PATCH 30/38] Prevent agents from reconstructing signed handshake commands Return a complete pinned-helper command and require byte-for-byte reuse of role access so fresh clients cannot corrupt signed inputs while reasoning over them. Constraint: Fresh Codex and Claude Code clients must execute the hosted MCP flow without plugins or repository-local state. Rejected: Continue returning a prefix and suffix for model-side concatenation | live Terra canary proved that model transcription can corrupt a role capability. Confidence: high Scope-risk: narrow Directive: Keep helper commands server-authored and executable verbatim; never require agent-side re-encoding of access or payload bytes. Tested: npm test in packages/mcp-server (289 tests); node --test infra/test/*.test.mjs (30 tests); git diff --check. Not-tested: Live production cross-client canary runs pending deployment of this revision. --- .../src/agent-handshake/v2/coordinator.ts | 47 +++++++++++-------- .../src/agent-handshake/v2/instructions.ts | 10 ++-- .../agent-handshake-v2-coordinator.test.mjs | 12 +++-- .../agent-handshake-v2-public-server.test.mjs | 3 +- 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 77237b1..b32e0d0 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -11,6 +11,7 @@ import { recoverEip191Address, resolveOwnedAgentRegistration } from "../../hands import { authorizeV2RoleAccess, verifyV2RoleAccess, type V2AccessKey, type V2Role } from "./access.js"; import type { V2InvitationMetadata } from "./invitation-store.js"; import { createV2InvitationService, createV2InvitationStore } from "./invitation-store.js"; +import { readV2ReleasePin, verifiedV2HelperPrefix } from "./instructions.js"; import { normalizeV2Acceptance, normalizeV2Descriptor, @@ -176,7 +177,7 @@ function localStateDir(sessionId: string, role: V2Role): string { return `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`; } -function helperStep(operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { +function helperStep(verifiedHelperPrefix: string, operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { const stateDir = localStateDir(sessionId, role); const argvAfterVerifiedPrefix = [operation, "--state-dir", stateDir]; if (payload !== undefined) { @@ -185,10 +186,15 @@ function helperStep(operation: string, sessionId: string, role: V2Role, payload? const shellCommandSuffix = argvAfterVerifiedPrefix .map((value, index) => index === 2 ? `"${value}"` : value) .join(" "); - return Object.freeze({ operation, argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix), shellCommandSuffix }); + return Object.freeze({ + operation, + argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix), + shellCommand: `${verifiedHelperPrefix} ${shellCommandSuffix}`, + shellCommandSuffix, + }); } -function setupLocalAction(policy: JsonObject, sessionId: string, role: V2Role): JsonObject { +function setupLocalAction(verifiedHelperPrefix: string, policy: JsonObject, sessionId: string, role: V2Role): JsonObject { const stateDir = localStateDir(sessionId, role); return Object.freeze({ executor: "pinned_helper", @@ -197,9 +203,9 @@ function setupLocalAction(policy: JsonObject, sessionId: string, role: V2Role): policyPayload: policy, stateDirectoryCommand: `mkdir -p -m 700 "${stateDir}"`, helperSteps: Object.freeze([ - helperStep("init", sessionId, role), - helperStep("policy", sessionId, role, policy), - helperStep("inspect", sessionId, role), + helperStep(verifiedHelperPrefix, "init", sessionId, role), + helperStep(verifiedHelperPrefix, "policy", sessionId, role, policy), + helperStep(verifiedHelperPrefix, "inspect", sessionId, role), ]), stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", @@ -207,7 +213,7 @@ function setupLocalAction(policy: JsonObject, sessionId: string, role: V2Role): }); } -function signingLocalAction(signingRequest: JsonObject): JsonObject { +function signingLocalAction(verifiedHelperPrefix: string, signingRequest: JsonObject): JsonObject { const role = signingRequest.role as V2Role; const sessionId = signingRequest.sessionId as string; return Object.freeze({ @@ -215,13 +221,13 @@ function signingLocalAction(signingRequest: JsonObject): JsonObject { operation: "sign", payloadEncoding: "base64url_utf8_json", payload: signingRequest, - helperStep: helperStep("sign", sessionId, role, signingRequest), + helperStep: helperStep(verifiedHelperPrefix, "sign", sessionId, role, signingRequest), stateDir: "reuse_exact_absolute_state_dir", afterSuccess: "call_agent_handshake_submit_with_helper_output_and_unchanged_policy_digest", }); } -function certificateLocalAction(input: { +function certificateLocalAction(verifiedHelperPrefix: string, input: { certificate: JsonObject; discovery: JsonObject; role: V2Role; @@ -242,7 +248,7 @@ function certificateLocalAction(input: { operation: "verify-certificate", payloadEncoding: "base64url_utf8_json", payload, - helperStep: helperStep("verify-certificate", input.sessionId, input.role, payload), + helperStep: helperStep(verifiedHelperPrefix, "verify-certificate", input.sessionId, input.role, payload), stateDir: "reuse_exact_absolute_state_dir", terminalProof: "use_verified_helper_output_only", }); @@ -266,6 +272,7 @@ export function createV2Coordinator(options: { recoverEip191Address(input: { bytes: Buffer; signatureHex: string }): Promise; resolveRegistration(input: { address: string; fromBlock: string }): Promise; advanceTransitions(input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise; + verifiedHelperPrefix: string; }) { const store = options.stateStore ?? createHandshakeStateStore(); const now = options.now ?? Date.now; @@ -353,7 +360,7 @@ export function createV2Coordinator(options: { }); await storeInitial(created.initiatorAccess, metadata, "initiator"); const policy = localPolicy(terms, "initiator") as JsonObject; - return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(policy, found.sessionId, "initiator") }); + return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(options.verifiedHelperPrefix, policy, found.sessionId, "initiator") }); }, async acceptInvitation(invitation: string): Promise { @@ -366,7 +373,7 @@ export function createV2Coordinator(options: { }); const policy = localPolicy(accepted.metadata.terms as JsonObject, "responder") as JsonObject; const sessionId = (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId as string; - return Object.freeze({ responderAccess: accepted.responderAccess, sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(policy, sessionId, "responder") }); + return Object.freeze({ responderAccess: accepted.responderAccess, sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(options.verifiedHelperPrefix, policy, sessionId, "responder") }); }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { @@ -393,7 +400,7 @@ export function createV2Coordinator(options: { hostSessionKeyCertificate: auth.current.discovery.hostSessionKeyCertificate, repositorySha: auth.current.discovery.repositorySha, sessionDeadlineMs: auth.current.discovery.sessionDeadlineMs, signingRequest, - localAction: signingLocalAction(signingRequest), + localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest), }); }, @@ -409,7 +416,7 @@ export function createV2Coordinator(options: { if (!current.policyDigest || !current.sessionKeyAddress) fail(); if (current.pending) { const signingRequest = signRequest(current, role, current.pending.operation, current.pending.payload); - return Object.freeze({ stage: current.stage, signingRequest, localAction: signingLocalAction(signingRequest) }); + return Object.freeze({ stage: current.stage, signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); } const entries = (await options.relay.getMessages({ sessionId: auth.keyValue.session })).messages; if (!current.party) { @@ -429,7 +436,7 @@ export function createV2Coordinator(options: { executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: helperStep("register", auth.keyValue.session, role), + helperStep: helperStep(options.verifiedHelperPrefix, "register", auth.keyValue.session, role), afterSuccess: NEXT_ACTION, }), }); @@ -455,7 +462,7 @@ export function createV2Coordinator(options: { }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "proposal", payload: proposal }, stage: "sign_proposal" })); const signingRequest = signRequest(data(updated), role, "proposal", proposal); - return Object.freeze({ stage: "sign_proposal", signingRequest, localAction: signingLocalAction(signingRequest) }); + return Object.freeze({ stage: "sign_proposal", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); } if (role === "responder" && !current.acceptanceEnvelope) { if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); @@ -470,7 +477,7 @@ export function createV2Coordinator(options: { }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "acceptance", payload: acceptance }, stage: "sign_acceptance" })); const signingRequest = signRequest(data(updated), role, "acceptance", acceptance); - return Object.freeze({ stage: "sign_acceptance", signingRequest, localAction: signingLocalAction(signingRequest) }); + return Object.freeze({ stage: "sign_acceptance", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); } current = await refresh(auth.keyValue); if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); @@ -491,7 +498,7 @@ export function createV2Coordinator(options: { }, current.terms.identityPolicy) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { transitions, pending: { operation: "evidence", payload: evidence }, stage: "sign_evidence" })); const signingRequest = signRequest(data(updated), role, "evidence", evidence); - return Object.freeze({ stage: "sign_evidence", signingRequest, localAction: signingLocalAction(signingRequest) }); + return Object.freeze({ stage: "sign_evidence", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); }, async submit(input: { access: string; policyDigest: string; signatureHex: string }): Promise { @@ -541,7 +548,7 @@ export function createV2Coordinator(options: { await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateVerified: true, stage: "certificate_available" })); return Object.freeze({ certificate: envelope, - localAction: certificateLocalAction({ + localAction: certificateLocalAction(options.verifiedHelperPrefix, { certificate: envelope, discovery: auth.current.discovery, role: auth.verified.payload.role, @@ -650,6 +657,7 @@ export async function __advanceRuntimeV2(client: any, input: { descriptor: JsonO } export function createRuntimeV2Coordinator(env: Record = process.env) { + const releasePin = readV2ReleasePin(env); const activeAccessKey = accessKeyFromEnvironment(env.AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE); const accessKeys = [activeAccessKey]; if (env.AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS) accessKeys.push(accessKeyFromEnvironment(env.AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS)); @@ -681,5 +689,6 @@ export function createRuntimeV2Coordinator(env: Record __advanceRuntimeV2(clockchain, input), + verifiedHelperPrefix: verifiedV2HelperPrefix(releasePin), }); } diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 77f70f9..79b583f 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -14,7 +14,7 @@ const HELPER_FILENAME = "clockchain-agent-handshake.cjs"; export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.2"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; -function verifiedBootstrapPrefix(pin: V2ReleasePin): string { +export function verifiedV2HelperPrefix(pin: V2ReleasePin): string { return `node --input-type=commonjs --eval '${V2_VERIFIED_HELPER_BOOTSTRAP}' ${pin.manifestDigest} ./manifest.json ./${HELPER_FILENAME}`; } @@ -50,12 +50,12 @@ export function buildV2Instructions(rawPin: unknown): string { const helperUrl = `${pin.allowedAssetPrefix}${HELPER_FILENAME}`; const manifestDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json '${manifestUrl}'`; const helperDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./${HELPER_FILENAME} '${helperUrl}'`; - const bootstrap = verifiedBootstrapPrefix(pin); + const bootstrap = verifiedV2HelperPrefix(pin); return [ `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", - "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used.", + "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse the access string byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", "After agent_handshake_invite, use initiatorAccess only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for initiatorAccess.", "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", @@ -68,7 +68,7 @@ export function buildV2Instructions(rawPin: unknown): string { `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, - "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory beneath this client's isolated $TMPDIR. Then append each helperStep.shellCommandSuffix to the verified prefix verbatim and in returned order. Never reconstruct, edit, or re-encode a returned payload. If an operation's shellCommandSuffix does not include --payload-base64url, do not add that flag or any payload.", + "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory beneath this client's isolated $TMPDIR. Then run each helperStep.shellCommand verbatim and in returned order. The shellCommand already contains the verified prefix and exact payload: never concatenate it yourself, reconstruct it, edit it, or re-encode a returned payload. If an operation's shellCommand does not include --payload-base64url, do not add that flag or any payload.", "Use only the exact session-scoped $TMPDIR path returned by Clockchain for every local helper operation in this handshake. Do not assign it to another shell variable, replace it with $HOME or $PWD, create another state directory, or reuse state from another session.", "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", @@ -96,7 +96,7 @@ export function buildV2Manifest(rawPin: unknown) { manifestUrl: `${pin.allowedAssetPrefix}manifest.json`, helperUrl: `${pin.allowedAssetPrefix}${HELPER_FILENAME}`, nodeRuntimeMajor: "24", - verifiedBootstrapPrefix: verifiedBootstrapPrefix(pin), + verifiedBootstrapPrefix: verifiedV2HelperPrefix(pin), }), hostRoots: pin.hostRoots, supportedClients: Object.freeze(["codex", "claude-code"]), diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index f6fcf00..64f6674 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -18,6 +18,7 @@ const terms = { const sessionId = randomUUID(); const nowMs = 1786337000000; const repositorySha = "d".repeat(40); +const verifiedHelperPrefix = "node --verified-helper"; const hostSessionKeyCertificate = { certificate: { schema: "clockchain.host-session-key/v1", rootKid: "root-2026-08", sessionId, repositorySha, sessionPublicKey: "ore80hj1AhLMNPybJXCL6XHyJ9OfmaYSXc4SA8Sk2Pw=", validFromMs: String(nowMs), validUntilMs: String(nowMs + 600000) }, root: { algorithm: "ed25519", keyId: "root-2026-08", publicKey: "6Xgu+IYxQBDx8adVlHHWf9AUYoeo+eqWr8eVQqXrY0Y=", signature: "a".repeat(88) }, @@ -125,6 +126,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m message: { kind, sessionDigest: v2CanonicalRecord(descriptor).digest }, onChain: { blockHeight: String(7010 + index), ledgerId: `33333333-4444-4555-8666-77777777777${index}` }, })), + verifiedHelperPrefix, }); const invited = await coordinator.invite(terms); @@ -137,9 +139,9 @@ test("two distinct role capabilities drive the complete v2 local-signing state m policyPayload: policy("initiator"), stateDirectoryCommand: `mkdir -p -m 700 "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"`, helperSteps: [ - { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, - { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, - { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", initiatorStateDir], shellCommandSuffix: `inspect --state-dir "${initiatorStateDir}"` }, + { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommand: `${verifiedHelperPrefix} init --state-dir "${initiatorStateDir}"`, shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, + { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommand: `${verifiedHelperPrefix} policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}`, shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, + { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", initiatorStateDir], shellCommand: `${verifiedHelperPrefix} inspect --state-dir "${initiatorStateDir}"`, shellCommandSuffix: `inspect --state-dir "${initiatorStateDir}"` }, ], stateDir: "new_private_absolute_state_dir", registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", @@ -163,6 +165,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m "sign", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), ]); + assert.equal(joined.localAction.helperStep.shellCommand, `${verifiedHelperPrefix} ${joined.localAction.helperStep.shellCommandSuffix}`); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } const identityMessages = messages.filter((message) => message.kind === "agent_v2_identity_claim"); @@ -259,6 +262,7 @@ test("fresh identity registration is returned as an executable pinned-helper act recoverEip191Address: async () => address, resolveRegistration: async () => null, advanceTransitions: async () => [], + verifiedHelperPrefix, }); const invited = await coordinator.invite(terms); @@ -287,7 +291,7 @@ test("fresh identity registration is returned as an executable pinned-helper act executor: "pinned_helper", operation: "register", stateDir: "reuse_exact_absolute_state_dir", - helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`], shellCommandSuffix: `register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"` }, + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`], shellCommand: `${verifiedHelperPrefix} register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"`, shellCommandSuffix: `register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"` }, afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", }, }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index a0a9a78..a395af2 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -70,11 +70,12 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /never.*shared.*temp.*directory/is); assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); - assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*isolated \$TMPDIR.*shellCommandSuffix.*verbatim.*never.*re-encode.*payload/is); + assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*isolated \$TMPDIR.*helperStep\.shellCommand.*verbatim.*never.*concatenate.*re-encode.*payload/is); assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); + assert.match(instructions, /access.*byte-for-byte.*never.*decode.*re-encode.*shorten.*reconstruct/is); assert.match(instructions, /initiatorAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); assert.match(instructions, /responderAccess.*Responder.*original invitation.*never.*access argument/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); From 030807f1fdbb66c69a605f9c9f3a3ec07bb1f698 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:56:08 -0700 Subject: [PATCH 31/38] Keep MCP completion claims at the local verification boundary Constraint: The server may expose a certificate but cannot assert that a client executed local verification.\nRejected: Broad retry fallback | it hides programming and state-corruption failures.\nConfidence: high\nScope-risk: narrow\nDirective: Mark only certificate availability server-side and retry only explicitly classified transient errors.\nTested: npm test (all workspaces and 30/30 infra tests green)\nNot-tested: production cross-client canaries pending deployment --- .../mcp-server/src/agent-handshake/v2/coordinator.ts | 6 +++--- .../mcp-server/src/agent-handshake/v2/public-tools.ts | 9 ++++++++- .../test/agent-handshake-v2-coordinator.test.mjs | 11 ++++++++++- .../test/agent-handshake-v2-public-server.test.mjs | 5 +++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index b32e0d0..c285fbb 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -50,7 +50,7 @@ type CoordinatorData = JsonObject & { sessionDigest?: string; transitions?: JsonObject[]; evidenceUploaded?: boolean; - certificateVerified?: boolean; + certificateAvailable?: boolean; relay?: { senderKey: string }; stage?: string; }; @@ -107,7 +107,7 @@ function merge(current: HandshakeRecord | null, keyValue: HandshakeKey, patch: P return { ...(current ?? { ...keyValue, status: "active" }), data: { ...data(current), ...patch }, - status: patch.certificateVerified ? "complete" : "active", + status: "active", }; } @@ -545,7 +545,7 @@ export function createV2Coordinator(options: { result.policyDigests[auth.verified.payload.role] !== auth.current.policyDigest || result.parties[auth.verified.payload.role].sessionKeyAddress !== auth.current.sessionKeyAddress ) fail(); - await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateVerified: true, stage: "certificate_available" })); + await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateAvailable: true, stage: "certificate_available" })); return Object.freeze({ certificate: envelope, localAction: certificateLocalAction(options.verifiedHelperPrefix, { diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 9cbdf58..fb34b80 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -25,6 +25,12 @@ const TERMINAL_ERROR_NAMES = new Set([ "V2InvitationError", "V2RoleAccessError", ]); +const RETRYABLE_ERROR_NAMES = new Set([ + "HttpRequestError", + "RpcRequestError", + "TimeoutError", + "V2TransientCoordinatorError", +]); const SAFE_ERROR_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/; const identityPolicy = z.discriminatedUnion("erc8004", [ z.object({ @@ -95,7 +101,8 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void tool: definition.name, errorName, })); - const retryable = !TERMINAL_ERROR_NAMES.has((error as Error)?.name) && + const retryable = RETRYABLE_ERROR_NAMES.has((error as Error)?.name) && + !TERMINAL_ERROR_NAMES.has((error as Error)?.name) && (error as Error)?.message !== "rate_limited"; const body = retryable ? { error: "HANDSHAKE_TEMPORARILY_UNAVAILABLE", retryable: true, retryAfterMs: 5000 } diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 64f6674..44fa399 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -111,12 +111,13 @@ test("two distinct role capabilities drive the complete v2 local-signing state m [addresses.initiator]: { agentId: "9452", chainId: terms.identityPolicy.chainId, registryAddress: terms.identityPolicy.registryAddress, reference: `${terms.identityPolicy.chainId}:${terms.identityPolicy.registryAddress}:9452`, registrationTx: `0x${"a".repeat(64)}`, registrationBlock: "7000" }, [addresses.responder]: { agentId: "9453", chainId: terms.identityPolicy.chainId, registryAddress: terms.identityPolicy.registryAddress, reference: `${terms.identityPolicy.chainId}:${terms.identityPolicy.registryAddress}:9453`, registrationTx: `0x${"b".repeat(64)}`, registrationBlock: "7001" }, }; + const stateStore = createHandshakeStateStore({}); const coordinator = createV2Coordinator({ accessKeys: [key], activeAccessKey: key, invitationService: createV2InvitationService({ activeKey: key, verificationKeys: [key], store: createV2InvitationStore(), nowMs: () => nowMs + 1 }), relay, - stateStore: createHandshakeStateStore({}), + stateStore, now: () => nowMs + 1, recoverEip191Address: async ({ signatureHex }) => signatureHex.endsWith("1b") ? addresses.initiator : addresses.responder, resolveRegistration: async ({ address }) => registrations[address] ?? null, @@ -236,6 +237,14 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); assert.equal(responderCertificate.localAction.payload.role, "responder"); + const certificateRecords = await stateStore.list(); + assert.equal(certificateRecords.length, 2); + for (const record of certificateRecords) { + assert.equal(record.status, "active"); + assert.equal(record.data.stage, "certificate_available"); + assert.equal(record.data.certificateAvailable, true); + assert.equal(Object.hasOwn(record.data, "certificateVerified"), false); + } }); test("fresh identity registration is returned as an executable pinned-helper action", async () => { diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index a395af2..c51e196 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -155,7 +155,9 @@ test("public tools distinguish retryable infrastructure failures from terminal p try { for (const candidate of [ { error: Object.assign(new Error("rpc unavailable"), { name: "RpcRequestError" }), retryable: true }, + { error: Object.assign(new Error("ledger is not durable yet"), { name: "V2TransientCoordinatorError" }), retryable: true }, { error: Object.assign(new Error("secret invalid role state"), { name: "V2CoordinatorError" }), retryable: false }, + { error: new Error("unexpected internal state"), retryable: false }, ]) { const handler = createV2PublicHttpHandler({ pin, invoke: async () => { throw candidate.error; } }); const httpServer = createServer((req, res) => handler(req, res)); @@ -179,7 +181,10 @@ test("public tools distinguish retryable infrastructure failures from terminal p } assert.deepEqual(warnings.map((entry) => JSON.parse(entry)), [ { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "RpcRequestError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "V2TransientCoordinatorError" }, { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "V2CoordinatorError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "Error" }, ]); assert.equal(warnings.join("\n").includes("secret invalid role state"), false); + assert.equal(warnings.join("\n").includes("unexpected internal state"), false); }); From 232931e608ccf68f4449090d1a6fbfa2eaee0fc3 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:02:52 -0700 Subject: [PATCH 32/38] Prevent manual digest transcription from blocking fresh agents Constraint: Fresh Codex and Claude Code clients must execute one machine-pinned helper command without relying on model transcription of a 64-character digest. Rejected: Keep the digest duplicated in prose and command | Claude Sonnet fabricated a trailing character and correctly stopped on the apparent mismatch. Confidence: high Scope-risk: narrow Directive: Keep the manifest digest authoritative in exactly one executable instruction location. Tested: npm test -- test/agent-handshake-v2-public-server.test.mjs Not-tested: Live cross-client production canary pending deployment. --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 2 +- .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 79b583f..c99fb83 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -52,7 +52,7 @@ export function buildV2Instructions(rawPin: unknown): string { const helperDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./${HELPER_FILENAME} '${helperUrl}'`; const bootstrap = verifiedV2HelperPrefix(pin); return [ - `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, + `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; the manifest digest is encoded only in the exact verified command below, so execute that command verbatim instead of transcribing or manually comparing the digest; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse the access string byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index c51e196..1c085a2 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -40,7 +40,9 @@ test("public initialization leads with the immutable local-authority boundary", const first = instructions.slice(0, 512); assert.match(first, /local signing/i); assert.match(first, /2\.1\.2/); - assert.ok(first.includes(pin.manifestDigest)); + assert.equal(first.includes(pin.manifestDigest), false); + assert.match(first, /digest is encoded only in the exact verified command below/i); + assert.equal(instructions.split(pin.manifestDigest).length - 1, 1); assert.ok(first.includes(pin.allowedAssetPrefix)); assert.ok(first.includes(pin.hostRoots[0].kid)); assert.ok(first.includes(pin.hostRoots[0].fingerprint)); From 811fbda0a246fa7ddf4967ae7adfef1f10c3b5c9 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:14:19 -0700 Subject: [PATCH 33/38] Keep role capability continuity explicit for agent clients Constraint: Fresh general-purpose agents must not reconstruct or remember a long bearer capability across waiting turns. Rejected: Rely on initialization prose alone | Codex used the exact capability repeatedly, then substituted a non-verifiable value after a wait response. Confidence: high Scope-risk: narrow Directive: Echo roleAccess on every successful role-scoped tool response and require the next call to use it verbatim. Tested: npm test (289 tests passed) Not-tested: Live cross-client production canary pending deployment. --- .../src/agent-handshake/v2/instructions.ts | 1 + .../src/agent-handshake/v2/public-tools.ts | 12 +++++++++++- .../test/agent-handshake-v2-public-server.test.mjs | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index c99fb83..b1d21ec 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -58,6 +58,7 @@ export function buildV2Instructions(rawPin: unknown): string { "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse the access string byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", "After agent_handshake_invite, use initiatorAccess only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for initiatorAccess.", "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", + "Every successful role-scoped response echoes roleAccess. Use it byte-for-byte as the immediately following role-scoped tool call's access argument; never replace it with a label, summary, placeholder, invitation, or remembered reconstruction.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index fb34b80..e1021d7 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -19,6 +19,13 @@ export type V2PublicToolName = typeof V2_PUBLIC_TOOL_NAMES[number]; export type V2PublicInvoke = (name: V2PublicToolName, args: Record) => Promise; const access = z.string().min(80).max(4096); +const ROLE_SCOPED_TOOLS = new Set([ + "agent_handshake_join", + "agent_handshake_status", + "agent_handshake_next", + "agent_handshake_submit", + "agent_handshake_get_certificate", +]); const TERMINAL_ERROR_NAMES = new Set([ "AgentHandshakeV2ValidationError", "V2CoordinatorError", @@ -90,7 +97,10 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void }, async (args: Record) => { try { const result = await invoke(definition.name, args); - return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result as Record }; + const body = ROLE_SCOPED_TOOLS.has(definition.name) + ? { ...(result as Record), roleAccess: args.access } + : result as Record; + return { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body }; } catch (error) { const observedName = (error as Error)?.name; const errorName = typeof observedName === "string" && SAFE_ERROR_NAME.test(observedName) diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 1c085a2..531f0b1 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -66,6 +66,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /session-scoped \$TMPDIR path.*every local helper operation.*do not assign.*shell variable.*replace.*\$HOME.*\$PWD/is); assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); + assert.match(instructions, /every successful role-scoped response echoes roleAccess.*use it byte-for-byte.*immediately following.*access argument/is); assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); assert.match(instructions, /statementDigest.*sha-256.*canonical.*terms object.*not.*raw statement text/is); assert.match(instructions, /already.*fresh.*disposable.*working directory.*do not create or switch to another working directory/is); @@ -120,6 +121,9 @@ test("the dedicated MCP server exposes exactly seven tools and no prompts or res assert.match(inviteSchema, /0x8004a818bfb912233c491871b3d84c89a494bd9e/); assert.match(inviteSchema, /required_fresh/); assert.equal(listed.body.result.tools.some((tool) => tool.annotations?.requiresUserInteraction === true), false); + const roleAccess = "r".repeat(80); + const status = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: roleAccess } }); + assert.equal(status.body.result.structuredContent.roleAccess, roleAccess); assert.equal((await rpc(url, "resources/list")).body.error.code, -32601); assert.equal((await rpc(url, "prompts/list")).body.error.code, -32601); } finally { From 8f2458c736bb283b109f4f8801b69cd5511604f4 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:21:29 -0700 Subject: [PATCH 34/38] Give fresh agents one capability field from invitation onward Constraint: A fresh client must use one stable field name for role authorization from its first join through certificate retrieval. Rejected: Preserve separate initiatorAccess, responderAccess, and roleAccess as equally authoritative names | General-purpose agents stochastically reconstructed the first token despite valid payload context. Confidence: high Scope-risk: narrow Directive: Keep roleAccess authoritative on invite, acceptance, and every successful role-scoped response; retain legacy aliases only for compatibility. Tested: npm test -- test/agent-handshake-v2-public-server.test.mjs Not-tested: Live cross-client production canary pending deployment. --- .../src/agent-handshake/v2/instructions.ts | 6 +++--- .../src/agent-handshake/v2/public-tools.ts | 14 +++++++++++--- .../test/agent-handshake-v2-public-server.test.mjs | 13 ++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index b1d21ec..8e60331 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -56,11 +56,11 @@ export function buildV2Instructions(rawPin: unknown): string { "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse the access string byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", - "After agent_handshake_invite, use initiatorAccess only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for initiatorAccess.", + "After agent_handshake_invite, use roleAccess (the same value retained as initiatorAccess for compatibility) only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for roleAccess.", "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "Every successful role-scoped response echoes roleAccess. Use it byte-for-byte as the immediately following role-scoped tool call's access argument; never replace it with a label, summary, placeholder, invitation, or remembered reconstruction.", - "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned Responder role access and never retry the consumed invitation.", - "After invitation acceptance, use responderAccess only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", + "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned roleAccess and never retry the consumed invitation.", + "After invitation acceptance, use roleAccess (the same value retained as responderAccess for compatibility) only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory and assigned a fresh session-scoped $TMPDIR. Stay in the working directory. Do not create or switch to another working directory, and never substitute /tmp, /private/tmp, /tmp/claude-*, or any other shared path. Every ./ path below means the current disposable working directory; every $TMPDIR path means the current isolated client session temp root.", diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index e1021d7..99b85c4 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -97,9 +97,17 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void }, async (args: Record) => { try { const result = await invoke(definition.name, args); - const body = ROLE_SCOPED_TOOLS.has(definition.name) - ? { ...(result as Record), roleAccess: args.access } - : result as Record; + const record = result as Record; + const authoritativeAccess = ROLE_SCOPED_TOOLS.has(definition.name) + ? args.access + : definition.name === "agent_handshake_invite" + ? record.initiatorAccess + : definition.name === "agent_handshake_accept_invitation" + ? record.responderAccess + : undefined; + const body = typeof authoritativeAccess === "string" + ? { ...record, roleAccess: authoritativeAccess } + : record; return { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body }; } catch (error) { const observedName = (error as Error)?.name; diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 531f0b1..6892996 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -79,8 +79,8 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); assert.match(instructions, /access.*byte-for-byte.*never.*decode.*re-encode.*shorten.*reconstruct/is); - assert.match(instructions, /initiatorAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); - assert.match(instructions, /responderAccess.*Responder.*original invitation.*never.*access argument/is); + assert.match(instructions, /roleAccess.*same value.*initiatorAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); + assert.match(instructions, /roleAccess.*same value.*responderAccess.*Responder.*original invitation.*never.*access argument/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); @@ -94,7 +94,12 @@ test("public initialization leads with the immutable local-authority boundary", test("the dedicated MCP server exposes exactly seven tools and no prompts or resources", async () => { const httpServer = createServer(async (req, res) => { - const server = buildV2PublicServer({ pin, invoke: async (name) => ({ ok: true, name }) }); + const server = buildV2PublicServer({ pin, invoke: async (name) => ({ + ok: true, + name, + ...(name === "agent_handshake_invite" ? { initiatorAccess: "i".repeat(80) } : {}), + ...(name === "agent_handshake_accept_invitation" ? { responderAccess: "r".repeat(80) } : {}), + }) }); const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => { void transport.close(); void server.close(); }); @@ -121,6 +126,8 @@ test("the dedicated MCP server exposes exactly seven tools and no prompts or res assert.match(inviteSchema, /0x8004a818bfb912233c491871b3d84c89a494bd9e/); assert.match(inviteSchema, /required_fresh/); assert.equal(listed.body.result.tools.some((tool) => tool.annotations?.requiresUserInteraction === true), false); + const invited = await rpc(url, "tools/call", { name: "agent_handshake_invite", arguments: { reference: "NS-1847", statement: "test", validForSeconds: "90", identityPolicy: { erc8004: "required_fresh", chainId: "eip155:11155111", registryAddress: "0x8004a818bfb912233c491871b3d84c89a494bd9e" } } }); + assert.equal(invited.body.result.structuredContent.roleAccess, "i".repeat(80)); const roleAccess = "r".repeat(80); const status = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: roleAccess } }); assert.equal(status.body.result.structuredContent.roleAccess, roleAccess); From a44cf918c537ef014a6333f978883ac70b13aea5 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:39:50 -0700 Subject: [PATCH 35/38] Keep signed role capabilities out of model context Fresh Codex runs preserved capability claims but occasionally altered the long HMAC signature after several tool calls. The dedicated public endpoint now exposes short opaque handles while retaining and verifying the original signed capability behind the server boundary. Constraint: Preserve the seven-tool contract, existing signed authorization, generic MCP, and bilateral flows. Rejected: Continue relying on prompt repetition and long-token echoing | two live fresh-agent runs still corrupted signature bytes. Confidence: high Scope-risk: moderate Directive: Do not expose initiatorAccess or responderAccess on the dedicated public endpoint; roleAccess must remain opaque and process-local. Tested: npm test -w @clockchain/mcp-server (290/290 pass); focused public-server test (5/5 pass); git diff --check. Not-tested: Live two-client production canary pending deployment of this commit. --- .../src/agent-handshake/v2/instructions.ts | 6 +- .../src/agent-handshake/v2/public-server.ts | 73 +++++++++++++++- .../src/agent-handshake/v2/public-tools.ts | 13 +-- .../agent-handshake-v2-public-server.test.mjs | 85 ++++++++++++++++++- 4 files changed, 164 insertions(+), 13 deletions(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 8e60331..2b6c73e 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -55,12 +55,12 @@ export function buildV2Instructions(rawPin: unknown): string { `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; the manifest digest is encoded only in the exact verified command below, so execute that command verbatim instead of transcribing or manually comparing the digest; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", - "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. Treat each returned role access value as a local bearer credential: keep it stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse the access string byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", - "After agent_handshake_invite, use roleAccess (the same value retained as initiatorAccess for compatibility) only for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for roleAccess.", + "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. roleAccess is a short opaque local handle; the server keeps the signed bearer capability behind that handle. Keep roleAccess stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse it byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", + "After agent_handshake_invite, use only the returned roleAccess for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for roleAccess.", "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", "Every successful role-scoped response echoes roleAccess. Use it byte-for-byte as the immediately following role-scoped tool call's access argument; never replace it with a label, summary, placeholder, invitation, or remembered reconstruction.", "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned roleAccess and never retry the consumed invitation.", - "After invitation acceptance, use roleAccess (the same value retained as responderAccess for compatibility) only for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", + "After invitation acceptance, use only the returned roleAccess for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory and assigned a fresh session-scoped $TMPDIR. Stay in the working directory. Do not create or switch to another working directory, and never substitute /tmp, /private/tmp, /tmp/claude-*, or any other shared path. Every ./ path below means the current disposable working directory; every $TMPDIR path means the current isolated client session temp root.", diff --git a/packages/mcp-server/src/agent-handshake/v2/public-server.ts b/packages/mcp-server/src/agent-handshake/v2/public-server.ts index e76ea38..9030fef 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-server.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-server.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -5,6 +6,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { buildV2Instructions, type V2ReleasePin } from "./instructions.js"; import { registerV2PublicTools, V2_PUBLIC_TOOL_NAMES, type V2PublicInvoke } from "./public-tools.js"; +import { V2RoleAccessError } from "./access.js"; export { V2_PUBLIC_TOOL_NAMES } from "./public-tools.js"; @@ -35,6 +37,74 @@ function limiter(limit: number, windowMs: number, now: () => number) { }; } +const ROLE_ACCESS_HANDLE = /^ccra_[A-Za-z0-9_-]{22}$/; +const ROLE_ACCESS_HANDLE_TTL_MS = 60 * 60_000; +const ROLE_ACCESS_HANDLE_LIMIT = 10_000; +const ROLE_SCOPED_TOOLS = new Set([ + "agent_handshake_join", + "agent_handshake_status", + "agent_handshake_next", + "agent_handshake_submit", + "agent_handshake_get_certificate", +]); + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new V2RoleAccessError(); + return value as Record; +} + +function createRoleAccessBroker(invoke: V2PublicInvoke, now: () => number): V2PublicInvoke { + const handles = new Map(); + + function prune(): void { + const current = now(); + for (const [handle, entry] of handles) { + if (current >= entry.expiresAt) handles.delete(handle); + } + } + + function issue(access: unknown): string { + if (typeof access !== "string" || access.length < 80 || access.length > 4096) throw new V2RoleAccessError(); + prune(); + if (handles.size >= ROLE_ACCESS_HANDLE_LIMIT) throw new V2RoleAccessError(); + let handle: string; + do { handle = `ccra_${randomBytes(16).toString("base64url")}`; } while (handles.has(handle)); + handles.set(handle, { access, expiresAt: now() + ROLE_ACCESS_HANDLE_TTL_MS }); + return handle; + } + + function resolve(value: unknown): { clientAccess: string; signedAccess: string } { + if (typeof value !== "string") throw new V2RoleAccessError(); + if (!ROLE_ACCESS_HANDLE.test(value)) return { clientAccess: "", signedAccess: value }; + prune(); + const entry = handles.get(value); + if (!entry) throw new V2RoleAccessError(); + return { clientAccess: value, signedAccess: entry.access }; + } + + return async (name, args) => { + if (ROLE_SCOPED_TOOLS.has(name)) { + const resolved = resolve(args.access); + const result = object(await invoke(name, { ...args, access: resolved.signedAccess })); + const roleAccess = resolved.clientAccess || issue(resolved.signedAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + const result = object(await invoke(name, args)); + if (name === "agent_handshake_invite") { + const roleAccess = issue(result.initiatorAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + if (name === "agent_handshake_accept_invitation") { + const roleAccess = issue(result.responderAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + return result; + }; +} + export function buildV2PublicServer(options: { pin: V2ReleasePin; invoke: V2PublicInvoke }): McpServer { const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.2" }, { instructions: buildV2Instructions(options.pin), @@ -54,6 +124,7 @@ export function createV2PublicHttpHandler(options: { const now = options.now ?? Date.now; const allowInvite = limiter(options.invitePerHour ?? 5, 60 * 60_000, now); const allowCall = limiter(options.callsPerMinute ?? 120, 60_000, now); + const invoke = createRoleAccessBroker(options.invoke, now); return async (req: IncomingMessage, res: ServerResponse): Promise => { if ((req.url ?? "").split("?")[0] !== "/handshake/mcp") { res.writeHead(404, { "content-type": "application/json" }); @@ -70,7 +141,7 @@ export function createV2PublicHttpHandler(options: { pin: options.pin, invoke: async (name, args) => { if (name === "agent_handshake_invite" && !allowInvite(`invite:${ip}`)) throw new Error("rate_limited"); - return options.invoke(name, args); + return invoke(name, args); }, }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 99b85c4..d832f42 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -18,7 +18,7 @@ export const V2_PUBLIC_TOOL_NAMES = Object.freeze([ export type V2PublicToolName = typeof V2_PUBLIC_TOOL_NAMES[number]; export type V2PublicInvoke = (name: V2PublicToolName, args: Record) => Promise; -const access = z.string().min(80).max(4096); +const access = z.string().min(27).max(4096); const ROLE_SCOPED_TOOLS = new Set([ "agent_handshake_join", "agent_handshake_status", @@ -98,16 +98,19 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void try { const result = await invoke(definition.name, args); const record = result as Record; - const authoritativeAccess = ROLE_SCOPED_TOOLS.has(definition.name) - ? args.access + const authoritativeAccess = typeof record.roleAccess === "string" + ? record.roleAccess + : ROLE_SCOPED_TOOLS.has(definition.name) + ? args.access : definition.name === "agent_handshake_invite" ? record.initiatorAccess : definition.name === "agent_handshake_accept_invitation" ? record.responderAccess : undefined; + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicRecord } = record; const body = typeof authoritativeAccess === "string" - ? { ...record, roleAccess: authoritativeAccess } - : record; + ? { ...publicRecord, roleAccess: authoritativeAccess } + : publicRecord; return { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body }; } catch (error) { const observedName = (error as Error)?.name; diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 6892996..561e5f5 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -52,7 +52,7 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Node 24/); assert.ok(instructions.includes(V2_VERIFIED_HELPER_BOOTSTRAP)); assert.match(instructions, /compile only those verified bytes in memory/i); - assert.match(instructions, /local bearer credential/i); + assert.match(instructions, /short opaque local handle/i); assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); assert.match(instructions, /inspect the downloaded manifest and helper source before execution/i); assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/manifest.json'")); @@ -79,8 +79,9 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); assert.match(instructions, /access.*byte-for-byte.*never.*decode.*re-encode.*shorten.*reconstruct/is); - assert.match(instructions, /roleAccess.*same value.*initiatorAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); - assert.match(instructions, /roleAccess.*same value.*responderAccess.*Responder.*original invitation.*never.*access argument/is); + assert.match(instructions, /roleAccess.*short opaque local handle.*signed bearer capability.*behind.*handle/is); + assert.match(instructions, /agent_handshake_invite.*only.*roleAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); + assert.match(instructions, /invitation acceptance.*only.*roleAccess.*Responder.*original invitation.*never.*access argument/is); assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); @@ -128,6 +129,7 @@ test("the dedicated MCP server exposes exactly seven tools and no prompts or res assert.equal(listed.body.result.tools.some((tool) => tool.annotations?.requiresUserInteraction === true), false); const invited = await rpc(url, "tools/call", { name: "agent_handshake_invite", arguments: { reference: "NS-1847", statement: "test", validForSeconds: "90", identityPolicy: { erc8004: "required_fresh", chainId: "eip155:11155111", registryAddress: "0x8004a818bfb912233c491871b3d84c89a494bd9e" } } }); assert.equal(invited.body.result.structuredContent.roleAccess, "i".repeat(80)); + assert.equal("initiatorAccess" in invited.body.result.structuredContent, false); const roleAccess = "r".repeat(80); const status = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: roleAccess } }); assert.equal(status.body.result.structuredContent.roleAccess, roleAccess); @@ -142,7 +144,17 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur assert.equal(v2PublicClientIp({ "x-forwarded-for": "203.0.113.9" }, "198.51.100.2", "198.51.100.1"), "198.51.100.2"); assert.equal(v2PublicClientIp({ "x-forwarded-for": "203.0.113.9, 198.51.100.1" }, "198.51.100.1", "198.51.100.1"), "203.0.113.9"); let now = 1000; - const handler = createV2PublicHttpHandler({ pin, now: () => now, invitePerHour: 5, callsPerMinute: 120, invoke: async (name) => ({ ok: true, name }) }); + const handler = createV2PublicHttpHandler({ + pin, + now: () => now, + invitePerHour: 5, + callsPerMinute: 120, + invoke: async (name) => ({ + ok: true, + name, + ...(name === "agent_handshake_invite" ? { initiatorAccess: `${"a".repeat(160)}.${"b".repeat(43)}` } : {}), + }), + }); const httpServer = createServer((req, res) => handler(req, res)); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; @@ -161,6 +173,71 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur } }); +test("public HTTP keeps signed role capabilities behind short opaque handles", async () => { + const initiatorCapability = `${"a".repeat(160)}.${"b".repeat(43)}`; + const responderCapability = `${"c".repeat(160)}.${"d".repeat(43)}`; + const observed = []; + const handler = createV2PublicHttpHandler({ + pin, + invoke: async (name, args) => { + observed.push({ name, args }); + if (name === "agent_handshake_invite") { + return { initiatorAccess: initiatorCapability, responderInvitation: "v".repeat(80) }; + } + if (name === "agent_handshake_accept_invitation") { + return { responderAccess: responderCapability, sessionId: "session" }; + } + return { ok: true, name }; + }, + }); + const httpServer = createServer((req, res) => handler(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; + try { + const invited = await rpc(url, "tools/call", { + name: "agent_handshake_invite", + arguments: { reference: "NS-1847", statement: "test", validForSeconds: "90", identityPolicy: { erc8004: "required_fresh", chainId: "eip155:11155111", registryAddress: "0x8004a818bfb912233c491871b3d84c89a494bd9e" } }, + }); + const initiatorHandle = invited.body.result.structuredContent.roleAccess; + assert.match(initiatorHandle, /^ccra_[A-Za-z0-9_-]{22}$/); + assert.equal("initiatorAccess" in invited.body.result.structuredContent, false); + assert.equal(JSON.stringify(invited.body).includes(initiatorCapability), false); + + const status = await rpc(url, "tools/call", { + name: "agent_handshake_status", + arguments: { access: initiatorHandle }, + }); + assert.equal(status.body.result.structuredContent.roleAccess, initiatorHandle); + assert.equal(observed.at(-1).args.access, initiatorCapability); + assert.equal(JSON.stringify(status.body).includes(initiatorCapability), false); + + const accepted = await rpc(url, "tools/call", { + name: "agent_handshake_accept_invitation", + arguments: { invitation: "v".repeat(80) }, + }); + const responderHandle = accepted.body.result.structuredContent.roleAccess; + assert.match(responderHandle, /^ccra_[A-Za-z0-9_-]{22}$/); + assert.notEqual(responderHandle, initiatorHandle); + assert.equal("responderAccess" in accepted.body.result.structuredContent, false); + assert.equal(JSON.stringify(accepted.body).includes(responderCapability), false); + + await rpc(url, "tools/call", { + name: "agent_handshake_next", + arguments: { access: responderHandle }, + }); + assert.equal(observed.at(-1).args.access, responderCapability); + + const invalid = await rpc(url, "tools/call", { + name: "agent_handshake_next", + arguments: { access: `${responderHandle.slice(0, -1)}x` }, + }); + assert.equal(invalid.body.result.isError, true); + assert.equal(observed.at(-1).args.access, responderCapability); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } +}); + test("public tools distinguish retryable infrastructure failures from terminal protocol rejection", async () => { const warnings = []; const originalWarn = console.warn; From 33be79df907694868de53ebf05ced5695f541d27 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:40:39 -0700 Subject: [PATCH 36/38] Keep fresh-agent invitations retryable during host rotation Constraint: The production host rotates short-lived discovery sessions, and an invite can race the expiry boundary. Rejected: Treating an expired current discovery record as a terminal protocol rejection | the caller has not violated protocol and can safely retry unchanged. Confidence: high Scope-risk: narrow Directive: Preserve terminal failures for malformed or unauthorized invitations; only infrastructure rotation remains retryable. Tested: npm test in packages/mcp-server (291/291); focused coordinator and public-server tests (9/9); git diff --check. Not-tested: No second production canary was run because the authorized one-attempt gate has been consumed. --- .../src/agent-handshake/v2/coordinator.ts | 2 +- .../agent-handshake-v2-coordinator.test.mjs | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index c285fbb..47fa277 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -349,7 +349,7 @@ export function createV2Coordinator(options: { async invite(value: unknown): Promise { const terms = normalizeV2Terms(value) as JsonObject; const found = discovery(await options.relay.fetchDiscovery()); - if (now() >= Number(found.invitationExpiresAtMs)) fail(); + if (now() >= Number(found.invitationExpiresAtMs)) transient(); const metadata = metadataFrom(found, terms); const created = await options.invitationService.create({ sessionId: found.sessionId, diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index 44fa399..ccea6a6 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -83,6 +83,37 @@ test("an unanchored Clockchain ledger response is retryable instead of a termina ); }); +test("an expired current invitation window is retryable while the host rotates sessions", async () => { + __resetHandshakeStateStore(); + const key = { kid: "role-2026-08", secret: randomBytes(32) }; + const coordinator = createV2Coordinator({ + accessKeys: [key], + activeAccessKey: key, + invitationService: createV2InvitationService({ + activeKey: key, + verificationKeys: [key], + store: createV2InvitationStore(), + nowMs: () => nowMs + 120000, + }), + relay: { + fetchDiscovery: async () => discovery, + getMessages: async () => ({ messages: [] }), + postMessage: async () => ({ ok: true, seq: "1" }), + }, + stateStore: createHandshakeStateStore({}), + now: () => nowMs + 120000, + recoverEip191Address: async () => "0x7564105e977516c53be337314c7e53838967bdac", + resolveRegistration: async () => null, + advanceTransitions: async () => [], + verifiedHelperPrefix, + }); + + await assert.rejects( + () => coordinator.invite(terms), + (error) => error?.name === "V2TransientCoordinatorError", + ); +}); + test("two distinct role capabilities drive the complete v2 local-signing state machine", async () => { __resetHandshakeStateStore(); const key = { kid: "role-2026-08", secret: randomBytes(32) }; From ddd8e93d4e07a72817d5e6dd09649ec4deae8bd5 Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:38:31 -0700 Subject: [PATCH 37/38] Signal when Person One starts the handshake Constraint: The public monitor must distinguish host readiness from stakeholder action without exposing either role capability. Rejected: Inferring start from session creation | the AWS host opens sessions before any stakeholder acts. Confidence: high Scope-risk: narrow Directive: Keep invitation-created additive, signed by the Initiator relay identity, and capability-free. Tested: MCP build plus v2 coordinator and public-server tests, 9/9 passing. Not-tested: Host consumption and production deployment follow in later tasks. --- .../src/agent-handshake/v2/coordinator.ts | 6 +++++- .../test/agent-handshake-v2-coordinator.test.mjs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 47fa277..9602ec5 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -358,7 +358,11 @@ export function createV2Coordinator(options: { expMs: found.sessionDeadlineMs, metadata, }); - await storeInitial(created.initiatorAccess, metadata, "initiator"); + const keyValue = await storeInitial(created.initiatorAccess, metadata, "initiator"); + await post(keyValue, "agent_v2_invitation_created", { + createdAtMs: String(now()), + externalBusinessActionPerformed: false, + }); const policy = localPolicy(terms, "initiator") as JsonObject; return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(options.verifiedHelperPrefix, policy, found.sessionId, "initiator") }); }, diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index ccea6a6..3473332 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -162,6 +162,19 @@ test("two distinct role capabilities drive the complete v2 local-signing state m }); const invited = await coordinator.invite(terms); + const invitationCreated = messages.find((message) => message.kind === "agent_v2_invitation_created"); + assert.equal(invitationCreated.role, "initiator"); + assert.equal(invitationCreated.sessionId, sessionId); + assert.deepEqual(Object.keys(invitationCreated.body).sort(), [ + "createdAtMs", + "externalBusinessActionPerformed", + ]); + assert.deepEqual(invitationCreated.body, { + createdAtMs: String(nowMs + 1), + externalBusinessActionPerformed: false, + }); + assert.equal(JSON.stringify(invitationCreated).includes(invited.responderInvitation), false); + assert.equal(JSON.stringify(invitationCreated).includes(invited.initiatorAccess), false); const initiatorStateDir = `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`; assert.deepEqual(invited.localPolicy, policy("initiator")); assert.deepEqual(invited.localAction, { @@ -183,6 +196,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m assert.deepEqual(accepted.localPolicy, policy("responder")); assert.deepEqual(accepted.localAction.policyPayload, policy("responder")); const invitationClaimed = messages.find((message) => message.kind === "agent_v2_invitation_claimed"); + assert.ok(messages.indexOf(invitationCreated) < messages.indexOf(invitationClaimed)); assert.equal(invitationClaimed.role, "responder"); assert.equal(invitationClaimed.body.claimedAtMs, String(nowMs + 1)); assert.notEqual(invited.initiatorAccess, accepted.responderAccess); From 0092ca278a33f83c4310a5c8ee20df887234e47a Mon Sep 17 00:00:00 2001 From: thetangstr <58705396+thetangstr@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:11:47 -0700 Subject: [PATCH 38/38] Keep the public helper bootstrap on the pinned runtime Make the production MCP command enforce both the manifest's Node 24 requirement and the executing runtime before loading verified helper bytes. Constraint: The mechanics-proof recorder accepts one exact verified bootstrap string. Rejected: Accepting the older production bootstrap in the recorder | that would weaken the emitted command contract. Confidence: high Scope-risk: narrow Directive: Change the MCP bootstrap and recorder bootstrap together and assert byte equality before live proofs. Tested: @clockchain/mcp-server test; 291 tests passed. Not-tested: Production endpoint canary awaits deploy. --- packages/mcp-server/src/agent-handshake/v2/instructions.ts | 2 +- .../mcp-server/test/agent-handshake-v2-public-server.test.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 2b6c73e..01606fc 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -12,7 +12,7 @@ const KID = /^[a-z0-9][a-z0-9-]{0,63}$/; const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/"; const HELPER_FILENAME = "clockchain-agent-handshake.cjs"; -export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.2"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; +export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.2"||!/^24\\./.test(manifest.nodeRuntime)||!/^24\\./.test(process.versions.node)||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; export function verifiedV2HelperPrefix(pin: V2ReleasePin): string { return `node --input-type=commonjs --eval '${V2_VERIFIED_HELPER_BOOTSTRAP}' ${pin.manifestDigest} ./manifest.json ./${HELPER_FILENAME}`; diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index 561e5f5..4964879 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -91,6 +91,9 @@ test("public initialization leads with the immutable local-authority boundary", assert.ok(manifest.helper.verifiedBootstrapPrefix.includes(V2_VERIFIED_HELPER_BOOTSTRAP)); assert.equal(V2_VERIFIED_HELPER_BOOTSTRAP.includes(","), false); assert.equal(V2_VERIFIED_HELPER_BOOTSTRAP.includes("'"), false); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /manifest\.nodeRuntime/); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /process\.versions\.node/); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /\^24/); }); test("the dedicated MCP server exposes exactly seven tools and no prompts or resources", async () => {