From 1cc2202b6a6a40b9aa07302d5675343f6d768d00 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:27:04 +0700 Subject: [PATCH 1/4] fix(protocol): freeze nego.open terms while pending Same-initiator redelivery must not escalate goal/mandate behind a stale pending gate (RT-PEND-1); remove preserveProgress overwrite path. --- .../nego-open-pending-mutation.probe.test.ts | 139 ++++++++++++++++++ .../src/session/state-machine.test.ts | 33 +++++ .../protocol/src/session/state-machine.ts | 24 ++- 3 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts diff --git a/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts b/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts new file mode 100644 index 0000000..2127e25 --- /dev/null +++ b/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts @@ -0,0 +1,139 @@ +/** + * Regression: pending-state nego.open redelivery must freeze first-open terms. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { KeyPair } from "../crypto/keys.js"; +import type { SessionStateMachine } from "./state-machine.js"; +import { + type MockPendingQueue, + createLinkedMachines, + createSessionTestFixtures, + defaultOpenPayload, +} from "./test-helpers.js"; + +describe("nego.open pending redelivery freezes first-open terms", () => { + let aliceKeys: KeyPair; + let bobKeys: KeyPair; + let carolKeys: KeyPair; + let aliceId: string; + let bobId: string; + let carolId: string; + let aliceMachine: SessionStateMachine; + let bobMachine: SessionStateMachine; + let bobPending: MockPendingQueue; + + beforeEach(() => { + vi.useFakeTimers(); + const fixtures = createSessionTestFixtures(); + aliceKeys = fixtures.aliceKeys; + bobKeys = fixtures.bobKeys; + carolKeys = fixtures.carolKeys; + aliceId = fixtures.aliceId; + bobId = fixtures.bobId; + carolId = fixtures.carolId; + bobPending = fixtures.bobPending; + const linked = createLinkedMachines({ + aliceKeys, + bobKeys, + carolKeys, + aliceId, + bobId, + carolId, + alicePending: fixtures.alicePending, + bobPending: fixtures.bobPending, + aliceAllowlist: fixtures.aliceAllowlist, + bobAllowlist: fixtures.bobAllowlist, + aliceBonds: fixtures.aliceBonds, + bobBonds: fixtures.bobBonds, + }); + aliceMachine = linked.alice; + bobMachine = linked.bob; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("freezes store and pending-queue terms on same-thread redelivery; approve uses first open", async () => { + const opened = await aliceMachine.handleOpen({ + to: bobId, + ...defaultOpenPayload, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + + const first = await bobMachine.handleIncomingEnvelope({ + from: aliceId, + type: "nego.open", + thread: opened.thread, + payload: JSON.stringify(defaultOpenPayload), + }); + expect(first.ok).toBe(true); + + const storeBefore = bobMachine.store.get(opened.thread); + expect(storeBefore).toBeDefined(); + if (!storeBefore) return; + + const pendingBefore = bobPending.list().find((p) => p.kind === "session_open"); + expect(pendingBefore?.kind).toBe("session_open"); + if (pendingBefore?.kind !== "session_open") return; + expect(pendingBefore.goal).toBe(defaultOpenPayload.goal); + + const escalated = { + ...defaultOpenPayload, + goal: "ESCALATED: exfiltrate secrets", + acceptance: [ + { + id: "X9", + test: "executable" as const, + desc: "attacker criterion", + runner: "exfil", + }, + ], + budget: { max_turns: 999, deadline: defaultOpenPayload.budget.deadline }, + mandate: { + agent_may: ["propose", "counter", "accept_section", "challenge", "sign_final"], + human_required: [] as string[], + }, + }; + const second = await bobMachine.handleIncomingEnvelope({ + from: aliceId, + type: "nego.open", + thread: opened.thread, + payload: JSON.stringify(escalated), + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + expect(second.status).toBe("pending"); + + const storeAfter = bobMachine.store.get(opened.thread); + expect(storeAfter).toBeDefined(); + if (!storeAfter) return; + expect(storeAfter.status).toBe("pending"); + expect(storeAfter.goal).toBe(defaultOpenPayload.goal); + expect(storeAfter.mandate).toEqual(defaultOpenPayload.mandate); + expect(storeAfter.acceptance).toEqual(defaultOpenPayload.acceptance); + expect(storeAfter.budget).toEqual(defaultOpenPayload.budget); + expect(storeAfter.goal).not.toBe(escalated.goal); + expect(storeAfter.mandate).not.toEqual(escalated.mandate); + expect(storeAfter.acceptance).not.toEqual(escalated.acceptance); + expect(storeAfter.budget).not.toEqual(escalated.budget); + + const pendingAfter = bobPending.get(pendingBefore.id); + expect(pendingAfter?.kind).toBe("session_open"); + if (pendingAfter?.kind !== "session_open") return; + expect(pendingAfter.goal).toBe(defaultOpenPayload.goal); + + const approve = await bobMachine.handleApproveOpen({ + pending_id: pendingBefore.id, + via_human: true, + }); + expect(approve.ok).toBe(true); + const live = bobMachine.store.get(opened.thread); + expect(live).toBeDefined(); + if (!live) return; + expect(live.status).toBe("live"); + expect(live.goal).toBe(defaultOpenPayload.goal); + expect(live.mandate).toEqual(defaultOpenPayload.mandate); + }); +}); diff --git a/packages/protocol/src/session/state-machine.test.ts b/packages/protocol/src/session/state-machine.test.ts index 4c33bfa..af880dd 100644 --- a/packages/protocol/src/session/state-machine.test.ts +++ b/packages/protocol/src/session/state-machine.test.ts @@ -666,6 +666,39 @@ describe("session state machine", () => { expect(bobAfter.goal).toBe(bobBefore.goal); }); + it("same-initiator nego.open redelivery on pending returns ok without mutating goal", async () => { + const opened = await aliceMachine.handleOpen({ + to: bobId, + ...openPayload, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) { + return; + } + + const before = bobMachine.store.get(opened.thread); + expect(before?.status).toBe("pending"); + expect(before?.goal).toBe(openPayload.goal); + if (!before) { + return; + } + + const redelivered = await bobMachine.handleIncomingOpen({ + thread: opened.thread, + from: aliceId, + goal: "mutated goal must not apply", + acceptance: openPayload.acceptance, + budget: openPayload.budget, + mandate: openPayload.mandate, + }); + expect(redelivered).toEqual({ + ok: true, + thread: opened.thread, + status: "pending", + }); + expect(bobMachine.store.get(opened.thread)?.goal).toBe(openPayload.goal); + }); + it("session_msg supports propose/counter/accept negotiation", async () => { const thread = await approveOpenSession(); diff --git a/packages/protocol/src/session/state-machine.ts b/packages/protocol/src/session/state-machine.ts index ad8cef3..76f4f9c 100644 --- a/packages/protocol/src/session/state-machine.ts +++ b/packages/protocol/src/session/state-machine.ts @@ -376,7 +376,15 @@ export function createSessionStateMachine( return { ok: false, error: "initiator_mismatch" }; } - const preserveProgress = existing?.status === "pending"; + // First-open terms are frozen while pending; same-initiator redelivery is a no-op. + if (existing?.status === "pending") { + return { + ok: true, + thread: input.thread, + status: "pending", + }; + } + const createdAt = existing?.createdAt ?? now(); const session: SessionRecord = { thread: input.thread, @@ -390,13 +398,13 @@ export function createSessionStateMachine( mandate: input.mandate, createdAt, expiresAt: createdAt + SESSION_OPEN_TTL_MS, - turnCount: preserveProgress ? existing.turnCount : 0, - peerMessages: preserveProgress ? existing.peerMessages : [], - lockedSections: preserveProgress ? existing.lockedSections : [], - testReports: preserveProgress ? existing.testReports : {}, - challenges: preserveProgress ? existing.challenges : {}, - signHashes: preserveProgress ? existing.signHashes : {}, - ratifyApproved: preserveProgress ? existing.ratifyApproved : {}, + turnCount: 0, + peerMessages: [], + lockedSections: [], + testReports: {}, + challenges: {}, + signHashes: {}, + ratifyApproved: {}, }; upsert(session); From bfa5e658981eae6ca6d0bc4c3c4c306fbb29c3f4 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:02:45 +0700 Subject: [PATCH 2/4] test(mcp-server): add M3.3 adversarial e2e suite Cover the six ROADMAP defenses plus nego.open redelivery across pending/live/signed/closed/open_rejected, and point the checklist at it. --- docs/conformance-checklist.md | 4 +- .../mcp-server/src/e2e/adversarial.test.ts | 453 ++++++++++++++++++ 2 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/src/e2e/adversarial.test.ts diff --git a/docs/conformance-checklist.md b/docs/conformance-checklist.md index 463574e..9584ac4 100644 --- a/docs/conformance-checklist.md +++ b/docs/conformance-checklist.md @@ -23,7 +23,7 @@ Maps every normative **MUST** / **MUST NOT** in [SPEC.md](../SPEC.md) §1.1 (Cor **Golden vectors:** Third-party byte-match fixtures live in [`packages/protocol/fixtures/`](../packages/protocol/fixtures/) — see [`fixtures/README.md`](../packages/protocol/fixtures/README.md). CI runs `pnpm --filter @agentpair/protocol run verify-fixtures`. -**E2E smoke (full stack):** `packages/mcp-server/src/e2e/happy-path.test.ts`, `profile-pairing.test.ts`, `spillover-roundtrip.test.ts` — pair → negotiate → ratify over a live relay. +**E2E smoke (full stack):** `packages/mcp-server/src/e2e/happy-path.test.ts`, `profile-pairing.test.ts`, `spillover-roundtrip.test.ts` — pair → negotiate → ratify over a live relay. Adversarial defenses + `nego.open` redelivery matrix: `packages/mcp-server/src/e2e/adversarial.test.ts` (M3.3). --- @@ -175,7 +175,7 @@ Tracked elsewhere in [ROADMAP.md](../ROADMAP.md): | Negotiation rules N1–N7 | §8 | M2.3–M2.5 (done); session tests in `state-machine.test.ts` | | Human gate `approval_code` provenance | §8.4, A4, §11.3 | M3.2 security audit | | Single-use pairing codes | §11.3 | covered — `packages/protocol/src/pairing/flow.test.ts` — `InMemoryPairingRegistry consume/tombstone`; `single-use burn + reject poll (T2)` | -| Adversarial e2e (tampered outer `to`, replay, self-approval) | §11.2 | M3.3 | +| Adversarial e2e (tampered outer `to`, replay, oversized, unbonded, self-approval, `nego.open` redelivery) | §11.2 | M3.3 — `packages/mcp-server/src/e2e/adversarial.test.ts` (states: pending/live/signed/closed/open_rejected; `open_expired` redelivery covered by `packages/protocol/src/session/state-machine.test.ts`) | --- diff --git a/packages/mcp-server/src/e2e/adversarial.test.ts b/packages/mcp-server/src/e2e/adversarial.test.ts new file mode 100644 index 0000000..fce417c --- /dev/null +++ b/packages/mcp-server/src/e2e/adversarial.test.ts @@ -0,0 +1,453 @@ +import { + createOuterEnvelope, + defaultEnvelopeTtl, + generateKeyPair, + serializeOuterEnvelope, +} from "@agentpair/protocol"; +/** + * M3.3 adversarial e2e suite (issue #37). + * Six ROADMAP defenses + nego.open redelivery matrix via handleInbox / relay POST + * (not syncInboxes — that bypasses §4.3). + * + * Session statuses covered here: pending, live, signed, closed, open_rejected. + * open_expired redelivery is credited to protocol unit tests (see conformance checklist). + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readApprovalCodeForAgent } from "../tools/approval-test-helpers.js"; +import { handleHumanApprove } from "../tools/human-approve.js"; +import { handleClose, handleInbox } from "../tools/inbox.js"; +import { handlePairInit, handlePairJoin } from "../tools/pair.js"; +import { + handleSessionMsg, + handleSessionOpen, + handleSessionSign, + handleSessionStatus, +} from "../tools/session.js"; +import { + type DualAgent, + type DualRelayEnv, + createDualAgent, + runPairingFlow, + startDualRelay, +} from "./dual-server.js"; + +function structured(result: { structuredContent: T }): T { + return result.structuredContent; +} + +const OPEN_PAYLOAD = { + goal: "benign adversarial goal", + acceptance: [ + { + id: "A1", + test: "executable" as const, + desc: "adversarial", + runner: "payload-size", + }, + ], + budget: { max_turns: 10, deadline: new Date(Date.now() + 86_400_000).toISOString() }, + mandate: { + agent_may: ["propose"], + human_required: ["sign_final"], + }, +}; + +const HIJACK_GOAL = "HIJACKED after state settled"; + +function unwrapGoal(goal: unknown): unknown { + if (typeof goal === "string") { + return goal; + } + if ( + goal && + typeof goal === "object" && + "data" in goal && + typeof (goal as { data: unknown }).data === "string" + ) { + return (goal as { data: string }).data; + } + return goal; +} + +async function redeliverNegoOpen( + sender: DualAgent, + recipient: DualAgent, + thread: string, + payload: typeof OPEN_PAYLOAD & { goal?: string }, +): Promise<{ seq: number }> { + await recipient.ctx.envelopeSeq.init(recipient.agentId); + const lastAccepted = recipient.ctx.envelopeSeq.getLastAccepted(thread, sender.agentId); + const seq = lastAccepted + 1; + + const senderKeys = await sender.ctx.keyStore.loadOrCreate(); + const envelope = createOuterEnvelope({ + sender: senderKeys, + recipientAgentId: recipient.agentId, + type: "nego.open", + thread, + seq, + ttl: defaultEnvelopeTtl(), + payload: new TextEncoder().encode(JSON.stringify(payload)), + }); + await sender.ctx.relay.sendEnvelope(recipient.agentId, envelope); + const after = structured(await handleInbox(recipient.ctx, {})); + expect(after.ok).toBe(true); + // Confirm the redelivery reached session dispatch (not dropped at seq/transport). + expect( + after.envelopes.some( + (envelope: { type?: string; seq?: number }) => + envelope.type === "nego.open" && envelope.seq === seq, + ), + ).toBe(true); + return { seq }; +} + +async function openSessionToPending( + alice: DualAgent, + bob: DualAgent, +): Promise<{ thread: string; pendingId: string }> { + const opened = structured( + await handleSessionOpen(alice.ctx, { + to: bob.agentId, + ...OPEN_PAYLOAD, + }), + ); + expect(opened.ok).toBe(true); + if (!opened.ok) { + throw new Error("session_open failed"); + } + const thread = opened.thread as string; + + const bootstrap = structured(await handleInbox(bob.ctx, { since: 0 })); + expect(bootstrap.ok).toBe(true); + + const pending = bob.ctx.pending.list().find((p) => p.kind === "session_open"); + expect(pending).toBeDefined(); + if (!pending) { + throw new Error("missing session_open pending"); + } + return { thread, pendingId: pending.id }; +} + +async function openSessionToLive(alice: DualAgent, bob: DualAgent): Promise<{ thread: string }> { + const { thread, pendingId } = await openSessionToPending(alice, bob); + const code = readApprovalCodeForAgent(bob.ctx, pendingId); + const approved = structured( + await handleHumanApprove(bob.ctx, { + pending_id: pendingId, + decision: "approve", + approval_code: code, + }), + ); + expect(approved.ok).toBe(true); + await handleInbox(alice.ctx, {}); + + const liveStatus = structured(await handleSessionStatus(bob.ctx, { thread })); + expect(liveStatus.ok).toBe(true); + if (!liveStatus.ok) { + throw new Error("live status failed"); + } + expect(liveStatus.status).toBe("live"); + return { thread }; +} + +/** Light path: msg + synthetic test_report + dual sign (no atest). Uses handleInbox only. */ +async function openSessionToSigned(alice: DualAgent, bob: DualAgent): Promise<{ thread: string }> { + const { thread } = await openSessionToLive(alice, bob); + const artifactHash = "sha256:m33-adversarial-signed"; + + for (const agent of [alice, bob]) { + await handleSessionMsg(agent.ctx, { + thread, + type: "challenge", + body: JSON.stringify({ report: "pass" }), + }); + const peer = agent === alice ? bob : alice; + await handleInbox(peer.ctx, {}); + } + + for (const agent of [alice, bob]) { + await handleSessionMsg(agent.ctx, { + thread, + type: "test_report", + body: JSON.stringify({ + artifact_hash: artifactHash, + passed: true, + runner: "payload-size", + }), + }); + const peer = agent === alice ? bob : alice; + await handleInbox(peer.ctx, {}); + } + + const aliceSign = structured( + await handleSessionSign(alice.ctx, { thread, artifact_hash: artifactHash }), + ); + expect(aliceSign.ok).toBe(true); + await handleInbox(bob.ctx, {}); + + const bobSign = structured( + await handleSessionSign(bob.ctx, { thread, artifact_hash: artifactHash }), + ); + expect(bobSign.ok).toBe(true); + await handleInbox(alice.ctx, {}); + + const status = structured(await handleSessionStatus(bob.ctx, { thread })); + expect(status.ok).toBe(true); + if (!status.ok) { + throw new Error("signed status failed"); + } + expect(status.status).toBe("signed"); + return { thread }; +} + +async function assertStatusAndGoal( + bob: DualAgent, + thread: string, + expectedStatus: string, + expectedGoal: string, +): Promise { + const status = structured(await handleSessionStatus(bob.ctx, { thread })); + expect(status.ok).toBe(true); + if (!status.ok) { + return; + } + expect(status.status).toBe(expectedStatus); + expect(unwrapGoal(status.goal)).toBe(expectedGoal); +} + +describe("M3.3 adversarial e2e (#37)", () => { + let env: DualRelayEnv; + + beforeAll(async () => { + env = await startDualRelay(13230); + }); + + afterAll(async () => { + await env.cleanup(); + }); + + it("1: tampered outer.to → relay routing_mismatch (400)", async () => { + const alice = await createDualAgent(env, "tamper-a"); + const bob = await createDualAgent(env, "tamper-b"); + await runPairingFlow(alice, bob); + + const aliceKeys = await alice.ctx.keyStore.loadOrCreate(); + const outer = createOuterEnvelope({ + sender: aliceKeys, + recipientAgentId: bob.agentId, + type: "core.msg", + thread: crypto.randomUUID(), + seq: 1, + ttl: defaultEnvelopeTtl(), + payload: new TextEncoder().encode(JSON.stringify({ body: "x" })), + }); + const wire = JSON.parse(serializeOuterEnvelope(outer)) as Record; + wire.to = "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + const res = await fetch(`${env.relayUrl}/inbox/${encodeURIComponent(bob.agentId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(wire), + }); + const body = (await res.json()) as { error?: string }; + expect(res.status).toBe(400); + expect(body.error).toBe("routing_mismatch"); + }, 30000); + + it("2: replayed seq → host stale_seq via handleInbox", async () => { + const alice = await createDualAgent(env, "replay-a"); + const bob = await createDualAgent(env, "replay-b"); + await runPairingFlow(alice, bob); + + const aliceKeys = await alice.ctx.keyStore.loadOrCreate(); + const thread = crypto.randomUUID(); + const ttl = defaultEnvelopeTtl(); + const first = createOuterEnvelope({ + sender: aliceKeys, + recipientAgentId: bob.agentId, + type: "core.msg", + thread, + seq: 1, + ttl, + payload: new TextEncoder().encode(JSON.stringify({ body: "first" })), + }); + const replay = createOuterEnvelope({ + sender: aliceKeys, + recipientAgentId: bob.agentId, + type: "core.msg", + thread, + seq: 1, + ttl, + id: crypto.randomUUID(), + payload: new TextEncoder().encode(JSON.stringify({ body: "replay" })), + }); + + await alice.ctx.relay.sendEnvelope(bob.agentId, first); + const okInbox = structured(await handleInbox(bob.ctx, { since: 0 })); + expect(okInbox.ok).toBe(true); + if (!okInbox.ok) return; + expect(okInbox.envelopes.some((e) => e.type === "core.msg")).toBe(true); + + await alice.ctx.relay.sendEnvelope(bob.agentId, replay); + const replayInbox = structured(await handleInbox(bob.ctx, {})); + expect(replayInbox.ok).toBe(true); + if (!replayInbox.ok) return; + expect(replayInbox.rejected?.some((r) => r.error === "stale_seq")).toBe(true); + }, 30000); + + it("3: oversized wire → relay envelope_too_large (413)", async () => { + const bob = await createDualAgent(env, "oversize-b"); + const huge = "x".repeat(70_000); + const res = await fetch(`${env.relayUrl}/inbox/${encodeURIComponent(bob.agentId)}`, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: huge, + }); + const body = (await res.json()) as { error?: string }; + expect(res.status).toBe(413); + expect(body.error).toBe("envelope_too_large"); + }, 15000); + + it("4: unbonded sender → relay recipient_not_allowed (403)", async () => { + const bob = await createDualAgent(env, "unbond-b"); + const stranger = generateKeyPair(); + const outer = createOuterEnvelope({ + sender: stranger, + recipientAgentId: bob.agentId, + type: "core.msg", + thread: crypto.randomUUID(), + seq: 1, + ttl: defaultEnvelopeTtl(), + payload: new TextEncoder().encode(JSON.stringify({ body: "nope" })), + }); + + const res = await fetch(`${env.relayUrl}/inbox/${encodeURIComponent(bob.agentId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serializeOuterEnvelope(outer), + }); + const body = (await res.json()) as { error?: string }; + expect(res.status).toBe(403); + expect(body.error).toBe("recipient_not_allowed"); + }, 15000); + + // Missing/empty approval_code is treated as self-approval (A4 / human-approve normalize path). + it("5: self-approval without approval_code → self_approval_forbidden", async () => { + const alice = await createDualAgent(env, "self-a"); + const bob = await createDualAgent(env, "self-b"); + const init = structured( + await handlePairInit(alice.ctx, { + scope: ["session.negotiate"], + mode: "ephemeral_until_session_closes", + }), + ); + expect(init.ok).toBe(true); + if (!init.ok) return; + + const join = structured(await handlePairJoin(bob.ctx, { code: init.code })); + expect(join.ok).toBe(true); + if (!join.ok) return; + + const denied = structured( + await handleHumanApprove(bob.ctx, { + pending_id: join.pending_id, + decision: "approve", + }), + ); + expect(denied.ok).toBe(false); + if (denied.ok) return; + expect(denied.error).toBe("self_approval_forbidden"); + }, 30000); + + describe("6: redelivered nego.open — no harmful side effects", () => { + it("pending: status + first-open terms frozen", async () => { + const alice = await createDualAgent(env, "redo-pend-a"); + const bob = await createDualAgent(env, "redo-pend-b"); + await runPairingFlow(alice, bob); + + const { thread } = await openSessionToPending(alice, bob); + await assertStatusAndGoal(bob, thread, "pending", OPEN_PAYLOAD.goal); + + await redeliverNegoOpen(alice, bob, thread, { + ...OPEN_PAYLOAD, + goal: HIJACK_GOAL, + }); + await assertStatusAndGoal(bob, thread, "pending", OPEN_PAYLOAD.goal); + }, 45000); + + it("live: status + terms unchanged", async () => { + const alice = await createDualAgent(env, "redo-live-a"); + const bob = await createDualAgent(env, "redo-live-b"); + await runPairingFlow(alice, bob); + + const { thread } = await openSessionToLive(alice, bob); + await redeliverNegoOpen(alice, bob, thread, { + ...OPEN_PAYLOAD, + goal: HIJACK_GOAL, + }); + await assertStatusAndGoal(bob, thread, "live", OPEN_PAYLOAD.goal); + }, 45000); + + it("signed: status + terms unchanged (light msg/report/sign path)", async () => { + const alice = await createDualAgent(env, "redo-sign-a"); + const bob = await createDualAgent(env, "redo-sign-b"); + await runPairingFlow(alice, bob); + + const { thread } = await openSessionToSigned(alice, bob); + await redeliverNegoOpen(alice, bob, thread, { + ...OPEN_PAYLOAD, + goal: HIJACK_GOAL, + }); + await assertStatusAndGoal(bob, thread, "signed", OPEN_PAYLOAD.goal); + }, 60000); + + it("closed: status unchanged after handleClose from live", async () => { + const alice = await createDualAgent(env, "redo-close-a"); + const bob = await createDualAgent(env, "redo-close-b"); + await runPairingFlow(alice, bob); + + const { thread } = await openSessionToLive(alice, bob); + const closed = structured( + await handleClose(alice.ctx, { + thread, + to: bob.agentId, + reason: "adversarial-close", + }), + ); + expect(closed.ok).toBe(true); + await handleInbox(bob.ctx, {}); + + await assertStatusAndGoal(bob, thread, "closed", OPEN_PAYLOAD.goal); + await redeliverNegoOpen(alice, bob, thread, { + ...OPEN_PAYLOAD, + goal: HIJACK_GOAL, + }); + await assertStatusAndGoal(bob, thread, "closed", OPEN_PAYLOAD.goal); + }, 45000); + + it("open_rejected: status unchanged after human reject", async () => { + const alice = await createDualAgent(env, "redo-rej-a"); + const bob = await createDualAgent(env, "redo-rej-b"); + await runPairingFlow(alice, bob); + + const { thread, pendingId } = await openSessionToPending(alice, bob); + const code = readApprovalCodeForAgent(bob.ctx, pendingId); + const rejected = structured( + await handleHumanApprove(bob.ctx, { + pending_id: pendingId, + decision: "reject:adversarial", + approval_code: code, + }), + ); + expect(rejected.ok).toBe(true); + + await assertStatusAndGoal(bob, thread, "open_rejected", OPEN_PAYLOAD.goal); + await redeliverNegoOpen(alice, bob, thread, { + ...OPEN_PAYLOAD, + goal: HIJACK_GOAL, + }); + await assertStatusAndGoal(bob, thread, "open_rejected", OPEN_PAYLOAD.goal); + }, 45000); + }); +}); From b862198aca193041b98d1625d0ca68b383a24afd Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:33:56 +0700 Subject: [PATCH 3/4] test: tighten pending freeze coverage from PR review Assert full goal/mandate/acceptance/budget freeze in e2e and unit tests, approve-after-hijack on pending, and drop redundant probe first delivery. --- .../mcp-server/src/e2e/adversarial.test.ts | 98 ++++++++++++++----- .../nego-open-pending-mutation.probe.test.ts | 26 ++--- .../src/session/state-machine.test.ts | 24 ++++- 3 files changed, 106 insertions(+), 42 deletions(-) diff --git a/packages/mcp-server/src/e2e/adversarial.test.ts b/packages/mcp-server/src/e2e/adversarial.test.ts index fce417c..9f01b8a 100644 --- a/packages/mcp-server/src/e2e/adversarial.test.ts +++ b/packages/mcp-server/src/e2e/adversarial.test.ts @@ -52,7 +52,22 @@ const OPEN_PAYLOAD = { }, }; -const HIJACK_GOAL = "HIJACKED after state settled"; +const HIJACK_PAYLOAD = { + goal: "HIJACKED after state settled", + acceptance: [ + { + id: "X9", + test: "executable" as const, + desc: "attacker criterion", + runner: "exfil", + }, + ], + budget: { max_turns: 999, deadline: OPEN_PAYLOAD.budget.deadline }, + mandate: { + agent_may: ["propose", "counter", "accept_section", "challenge", "sign_final"], + human_required: [] as string[], + }, +}; function unwrapGoal(goal: unknown): unknown { if (typeof goal === "string") { @@ -69,11 +84,37 @@ function unwrapGoal(goal: unknown): unknown { return goal; } +function assertStoreFirstOpenTerms(agent: DualAgent, thread: string): void { + const session = agent.ctx.sessionStore.get(thread); + expect(session).toBeDefined(); + if (!session) { + return; + } + expect(session.goal).toBe(OPEN_PAYLOAD.goal); + expect(session.mandate).toEqual(OPEN_PAYLOAD.mandate); + expect(session.acceptance).toEqual(OPEN_PAYLOAD.acceptance); + expect(session.budget).toEqual(OPEN_PAYLOAD.budget); + expect(session.goal).not.toBe(HIJACK_PAYLOAD.goal); + expect(session.mandate).not.toEqual(HIJACK_PAYLOAD.mandate); +} + +function assertPendingOpenFirstOpenTerms(agent: DualAgent, pendingId: string): void { + const pending = agent.ctx.pending.get(pendingId); + expect(pending?.kind).toBe("session_open"); + if (pending?.kind !== "session_open") { + return; + } + expect(pending.goal).toBe(OPEN_PAYLOAD.goal); + expect(pending.mandate).toEqual(OPEN_PAYLOAD.mandate); + expect(pending.acceptance).toEqual(OPEN_PAYLOAD.acceptance); + expect(pending.budget).toEqual(OPEN_PAYLOAD.budget); +} + async function redeliverNegoOpen( sender: DualAgent, recipient: DualAgent, thread: string, - payload: typeof OPEN_PAYLOAD & { goal?: string }, + payload: typeof OPEN_PAYLOAD | typeof HIJACK_PAYLOAD, ): Promise<{ seq: number }> { await recipient.ctx.envelopeSeq.init(recipient.agentId); const lastAccepted = recipient.ctx.envelopeSeq.getLastAccepted(thread, sender.agentId); @@ -361,32 +402,45 @@ describe("M3.3 adversarial e2e (#37)", () => { }, 30000); describe("6: redelivered nego.open — no harmful side effects", () => { - it("pending: status + first-open terms frozen", async () => { + it("pending: full terms frozen + approve still uses first open", async () => { const alice = await createDualAgent(env, "redo-pend-a"); const bob = await createDualAgent(env, "redo-pend-b"); await runPairingFlow(alice, bob); - const { thread } = await openSessionToPending(alice, bob); + const { thread, pendingId } = await openSessionToPending(alice, bob); await assertStatusAndGoal(bob, thread, "pending", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); + assertPendingOpenFirstOpenTerms(bob, pendingId); - await redeliverNegoOpen(alice, bob, thread, { - ...OPEN_PAYLOAD, - goal: HIJACK_GOAL, - }); + await redeliverNegoOpen(alice, bob, thread, HIJACK_PAYLOAD); await assertStatusAndGoal(bob, thread, "pending", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); + assertPendingOpenFirstOpenTerms(bob, pendingId); + expect(bob.ctx.pending.list().filter((p) => p.kind === "session_open")).toHaveLength(1); + + const code = readApprovalCodeForAgent(bob.ctx, pendingId); + const approved = structured( + await handleHumanApprove(bob.ctx, { + pending_id: pendingId, + decision: "approve", + approval_code: code, + }), + ); + expect(approved.ok).toBe(true); + await assertStatusAndGoal(bob, thread, "live", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); + expect(bob.ctx.pending.list().filter((p) => p.kind === "session_open")).toHaveLength(0); }, 45000); - it("live: status + terms unchanged", async () => { + it("live: full first-open terms unchanged", async () => { const alice = await createDualAgent(env, "redo-live-a"); const bob = await createDualAgent(env, "redo-live-b"); await runPairingFlow(alice, bob); const { thread } = await openSessionToLive(alice, bob); - await redeliverNegoOpen(alice, bob, thread, { - ...OPEN_PAYLOAD, - goal: HIJACK_GOAL, - }); + await redeliverNegoOpen(alice, bob, thread, HIJACK_PAYLOAD); await assertStatusAndGoal(bob, thread, "live", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); }, 45000); it("signed: status + terms unchanged (light msg/report/sign path)", async () => { @@ -395,11 +449,9 @@ describe("M3.3 adversarial e2e (#37)", () => { await runPairingFlow(alice, bob); const { thread } = await openSessionToSigned(alice, bob); - await redeliverNegoOpen(alice, bob, thread, { - ...OPEN_PAYLOAD, - goal: HIJACK_GOAL, - }); + await redeliverNegoOpen(alice, bob, thread, HIJACK_PAYLOAD); await assertStatusAndGoal(bob, thread, "signed", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); }, 60000); it("closed: status unchanged after handleClose from live", async () => { @@ -419,11 +471,9 @@ describe("M3.3 adversarial e2e (#37)", () => { await handleInbox(bob.ctx, {}); await assertStatusAndGoal(bob, thread, "closed", OPEN_PAYLOAD.goal); - await redeliverNegoOpen(alice, bob, thread, { - ...OPEN_PAYLOAD, - goal: HIJACK_GOAL, - }); + await redeliverNegoOpen(alice, bob, thread, HIJACK_PAYLOAD); await assertStatusAndGoal(bob, thread, "closed", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); }, 45000); it("open_rejected: status unchanged after human reject", async () => { @@ -443,11 +493,9 @@ describe("M3.3 adversarial e2e (#37)", () => { expect(rejected.ok).toBe(true); await assertStatusAndGoal(bob, thread, "open_rejected", OPEN_PAYLOAD.goal); - await redeliverNegoOpen(alice, bob, thread, { - ...OPEN_PAYLOAD, - goal: HIJACK_GOAL, - }); + await redeliverNegoOpen(alice, bob, thread, HIJACK_PAYLOAD); await assertStatusAndGoal(bob, thread, "open_rejected", OPEN_PAYLOAD.goal); + assertStoreFirstOpenTerms(bob, thread); }, 45000); }); }); diff --git a/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts b/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts index 2127e25..f350877 100644 --- a/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts +++ b/packages/protocol/src/session/nego-open-pending-mutation.probe.test.ts @@ -55,6 +55,7 @@ describe("nego.open pending redelivery freezes first-open terms", () => { }); it("freezes store and pending-queue terms on same-thread redelivery; approve uses first open", async () => { + // createLinkedMachines already delivers alice handleOpen into bob — no extra first open. const opened = await aliceMachine.handleOpen({ to: bobId, ...defaultOpenPayload, @@ -62,22 +63,18 @@ describe("nego.open pending redelivery freezes first-open terms", () => { expect(opened.ok).toBe(true); if (!opened.ok) return; - const first = await bobMachine.handleIncomingEnvelope({ - from: aliceId, - type: "nego.open", - thread: opened.thread, - payload: JSON.stringify(defaultOpenPayload), - }); - expect(first.ok).toBe(true); - const storeBefore = bobMachine.store.get(opened.thread); expect(storeBefore).toBeDefined(); if (!storeBefore) return; + expect(storeBefore.status).toBe("pending"); const pendingBefore = bobPending.list().find((p) => p.kind === "session_open"); expect(pendingBefore?.kind).toBe("session_open"); if (pendingBefore?.kind !== "session_open") return; expect(pendingBefore.goal).toBe(defaultOpenPayload.goal); + expect(pendingBefore.mandate).toEqual(defaultOpenPayload.mandate); + expect(pendingBefore.acceptance).toEqual(defaultOpenPayload.acceptance); + expect(pendingBefore.budget).toEqual(defaultOpenPayload.budget); const escalated = { ...defaultOpenPayload, @@ -96,15 +93,15 @@ describe("nego.open pending redelivery freezes first-open terms", () => { human_required: [] as string[], }, }; - const second = await bobMachine.handleIncomingEnvelope({ + const redelivered = await bobMachine.handleIncomingEnvelope({ from: aliceId, type: "nego.open", thread: opened.thread, payload: JSON.stringify(escalated), }); - expect(second.ok).toBe(true); - if (!second.ok) return; - expect(second.status).toBe("pending"); + expect(redelivered.ok).toBe(true); + if (!redelivered.ok) return; + expect(redelivered.status).toBe("pending"); const storeAfter = bobMachine.store.get(opened.thread); expect(storeAfter).toBeDefined(); @@ -123,6 +120,9 @@ describe("nego.open pending redelivery freezes first-open terms", () => { expect(pendingAfter?.kind).toBe("session_open"); if (pendingAfter?.kind !== "session_open") return; expect(pendingAfter.goal).toBe(defaultOpenPayload.goal); + expect(pendingAfter.mandate).toEqual(defaultOpenPayload.mandate); + expect(pendingAfter.acceptance).toEqual(defaultOpenPayload.acceptance); + expect(pendingAfter.budget).toEqual(defaultOpenPayload.budget); const approve = await bobMachine.handleApproveOpen({ pending_id: pendingBefore.id, @@ -135,5 +135,7 @@ describe("nego.open pending redelivery freezes first-open terms", () => { expect(live.status).toBe("live"); expect(live.goal).toBe(defaultOpenPayload.goal); expect(live.mandate).toEqual(defaultOpenPayload.mandate); + expect(live.acceptance).toEqual(defaultOpenPayload.acceptance); + expect(live.budget).toEqual(defaultOpenPayload.budget); }); }); diff --git a/packages/protocol/src/session/state-machine.test.ts b/packages/protocol/src/session/state-machine.test.ts index af880dd..7265f40 100644 --- a/packages/protocol/src/session/state-machine.test.ts +++ b/packages/protocol/src/session/state-machine.test.ts @@ -666,7 +666,7 @@ describe("session state machine", () => { expect(bobAfter.goal).toBe(bobBefore.goal); }); - it("same-initiator nego.open redelivery on pending returns ok without mutating goal", async () => { + it("same-initiator nego.open redelivery on pending freezes all first-open terms", async () => { const opened = await aliceMachine.handleOpen({ to: bobId, ...openPayload, @@ -687,16 +687,30 @@ describe("session state machine", () => { thread: opened.thread, from: aliceId, goal: "mutated goal must not apply", - acceptance: openPayload.acceptance, - budget: openPayload.budget, - mandate: openPayload.mandate, + acceptance: [ + { + id: "X9", + test: "executable", + desc: "attacker criterion", + runner: "exfil", + }, + ], + budget: { max_turns: 999, deadline: openPayload.budget.deadline }, + mandate: { + agent_may: ["propose", "counter", "accept_section", "challenge", "sign_final"], + human_required: [], + }, }); expect(redelivered).toEqual({ ok: true, thread: opened.thread, status: "pending", }); - expect(bobMachine.store.get(opened.thread)?.goal).toBe(openPayload.goal); + const after = bobMachine.store.get(opened.thread); + expect(after?.goal).toBe(openPayload.goal); + expect(after?.mandate).toEqual(openPayload.mandate); + expect(after?.acceptance).toEqual(openPayload.acceptance); + expect(after?.budget).toEqual(openPayload.budget); }); it("session_msg supports propose/counter/accept negotiation", async () => { From f73ae66f2347fde8324c7367592bea8a63af35d5 Mon Sep 17 00:00:00 2001 From: rfxlamia <222023708+rfxlamia@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:39:15 +0700 Subject: [PATCH 4/4] chore: nudge PR #68 head sync