From 42142178406a0b0e2caf6c6f3df45ad25202960f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sat, 3 Oct 2026 18:33:10 -0400 Subject: [PATCH] TASK-33d49b5b: compose bound owner reply MCP scenario --- docs/TEST_INTEGRITY.md | 27 ++++ src/mcp/tools.mjs | 29 ++++- test/fleet/mcp-owner-reply-wrapper.test.mjs | 137 ++++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 test/fleet/mcp-owner-reply-wrapper.test.mjs diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index d4bc2e9..8f75873 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -3100,3 +3100,30 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +## 2026-10-03 — Public MCP owner-reply wrapper + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-mcp-owner-reply-public-boundary + given: "A registered agent has an owner-originated request addressed to it and a second registered identity exists" + when: "Each identity invokes the public stdio MCP owner-reply tool" + then: "Only the bound addressee can persist a reply with replyTo and audit attribution; selector fields and another valid identity refuse before a write" + observable_outcomes: + - "The MCP schema exposes only request_id and body with no additional properties or owner-history tool" + - "The persisted reply is linked to the owner request and audited as the bound actor" + - "Selector-field and wrong-addressee refusals leave owner conversation and audit counts unchanged" + determinism_controls: + - "Hermetic Git/SQLite fixture, fixed topology, local stdio MCP process, no network, provider, sleep, or retry" + anti_cheat_rationale: + prevents: + - "Selecting a sender, recipient, owner, or history through MCP input" + - "Replying from a different valid Fleet identity" + - "Exposing an agent-facing owner-history reader" + - "Claiming an in-process helper as public MCP qualification" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index 818160e..32ca199 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -19,6 +19,7 @@ export const TORCH_CORE_MCP_TOOL_NAMES = Object.freeze([ 'torch_who_owns', 'torch_neighbours', 'torch_send_message', + 'torch_reply_to_owner', 'torch_read_messages', 'torch_ack_message', 'torch_report_status', @@ -100,6 +101,19 @@ function claimedIdentity(controlPlane, actorId, claim, field) { }); } +function boundActorIdentity(controlPlane, actorId) { + if (typeof actorId !== 'string' || actorId.trim().length === 0) { + throw new TorchError('torch_reply_to_owner requires an actual bound Fleet identity', { + code: 'FLEET_IDENTITY_REQUIRED', details: { field: 'bound_actor' }, + }); + } + return controlPlane.assertIdentity(actorId); +} + +function inputSchema(tool) { + return typeof tool.schema?.safeParse === 'function' ? tool.schema : z.object(tool.schema); +} + export function createTorchToolset(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, evolutionService, hierarchyService, repositoryRoot, @@ -169,6 +183,15 @@ export function createTorchToolset(controlPlane, { sender: claimedIdentity(controlPlane, actorId, sender, 'sender'), recipient, body, references, }), }], + ['torch_reply_to_owner', { + description: 'Reply to one owner-originated request as the bound Fleet identity. This tool cannot select a sender, recipient, owner, or conversation history.', + schema: z.object({ + request_id: z.string().min(1), body: z.string().min(1), + }).strict(), + invoke: ({ request_id: requestId, body }) => controlPlane.sendOwnerReply({ + sender: boundActorIdentity(controlPlane, actorId), requestId, body, + }), + }], ['torch_read_messages', { description: 'Read durable direct and group messages visible to one Fleet identity.', schema: { @@ -736,14 +759,14 @@ export function describeTorchTools() { }).entries()].map(([name, tool]) => ({ name, description: tool.description, - inputSchema: z.toJSONSchema(z.object(tool.schema)), + inputSchema: z.toJSONSchema(inputSchema(tool)), })); } export function callTorchTool(controlPlane, name, input = {}, options = {}) { const tool = createTorchToolset(controlPlane, options).get(name); if (!tool) throw new Error(`Unknown TORCH MCP tool: ${name}`); - const parsed = z.object(tool.schema).parse(input); + const parsed = inputSchema(tool).parse(input); return tool.invoke(parsed); } @@ -762,7 +785,7 @@ export function createTorchMcpServer(controlPlane, { actorId, backlogService, checkService, resourceService, integrationService, contextTelemetryService, scheduleService, evolutionService, hierarchyService, repositoryRoot, artifactService, })) { - server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => { + server.registerTool(name, { description: tool.description, inputSchema: inputSchema(tool) }, async (input) => { try { const result = await tool.invoke(input); return { diff --git a/test/fleet/mcp-owner-reply-wrapper.test.mjs b/test/fleet/mcp-owner-reply-wrapper.test.mjs new file mode 100644 index 0000000..682fcf6 --- /dev/null +++ b/test/fleet/mcp-owner-reply-wrapper.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; + +const MCP_SERVER = new URL('../../src/mcp/server.mjs', import.meta.url).pathname; + +function installedFixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-mcp-owner-reply-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'index.js'), 'export const fixture = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { status: 'approved', reviewedAt: '2026-10-03T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] }; + const env = { ...process.env, XDG_DATA_HOME: join(root, '.data') }; + installProject({ repository, proposal, env, projectId: 'mcp-owner-reply-fixture' }); + const control = openControlPlane({ repositoryRoot: root, env }); + const identities = control.listAgents().map((agent) => agent.areaId); + control.close(); + const worker = identities.find((areaId) => areaId !== 'qa' && areaId !== 'session-manager') + ?? identities.find((areaId) => areaId !== 'qa') + ?? identities[0]; + const other = identities.find((areaId) => areaId !== worker); + assert.ok(other, 'fixture requires a second registered Fleet identity'); + return { root, env, worker, other }; +} + +async function connectMcp({ root, env, actorId }) { + const child = spawn(process.execPath, [MCP_SERVER, '--root', root, '--area', actorId], { + cwd: root, env, stdio: ['pipe', 'pipe', 'pipe'], + }); + const responses = new Map(); + let stdout = ''; + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.stdout.on('data', (chunk) => { + stdout += chunk; + const lines = stdout.split('\n'); + stdout = lines.pop(); + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.id !== undefined) responses.get(message.id)?.(message); + } + }); + let nextId = 0; + const request = (method, params) => new Promise((resolve, reject) => { + const id = ++nextId; + const timeout = setTimeout(() => reject(new Error(`MCP response timeout. stderr: ${stderr}`)), 5000); + responses.set(id, (response) => { clearTimeout(timeout); resolve(response); }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + const initialized = await request('initialize', { + protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'torch-test', version: '1.0.0' }, + }); + assert.equal(initialized.result.serverInfo.name, 'torch-agent-fleet'); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); + return { + request, + async close() { + child.stdin.end(); + await new Promise((resolve) => { + const timeout = setTimeout(() => { child.kill('SIGTERM'); resolve(); }, 1000); + child.once('exit', () => { clearTimeout(timeout); resolve(); }); + }); + }, + }; +} + +test('SCN-mcp-owner-reply-public-boundary: a bound MCP identity can reply only to its own owner request', async () => { + const fixture = installedFixture(); + let control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + const request = control.sendOwnerRequest({ actorId: 'owner', recipient: fixture.worker, body: 'Report the durable outcome.' }); + control.close(); + + const workerMcp = await connectMcp({ ...fixture, actorId: fixture.worker }); + try { + const listed = await workerMcp.request('tools/list', {}); + const replyTool = listed.result.tools.find((tool) => tool.name === 'torch_reply_to_owner'); + assert.ok(replyTool); + assert.deepEqual(Object.keys(replyTool.inputSchema.properties).sort(), ['body', 'request_id']); + assert.equal(replyTool.inputSchema.additionalProperties, false); + assert.equal(listed.result.tools.some((tool) => /owner.*conversation|history/i.test(tool.name)), false); + + const replied = await workerMcp.request('tools/call', { + name: 'torch_reply_to_owner', arguments: { request_id: request.id, body: 'The durable outcome is ready.' }, + }); + assert.equal(replied.result.isError, undefined); + const reply = JSON.parse(replied.result.content[0].text); + + control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + const conversation = control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }); + assert.deepEqual(conversation.replies.map((message) => message.id), [reply.id]); + assert.equal(conversation.replies[0].references.replyTo, request.id); + const audit = control.readAudit().find((event) => event.operation === 'message.reply-owner' && event.entityId === reply.id); + assert.deepEqual(audit && { actorId: audit.actorId, details: audit.details }, { actorId: fixture.worker, details: { requestId: request.id } }); + const before = { audit: control.readAudit().length, replies: conversation.replies.length }; + control.close(); + + const schemaRefusal = await workerMcp.request('tools/call', { + name: 'torch_reply_to_owner', + arguments: { request_id: request.id, body: 'Attempt to select a sender.', sender: fixture.other }, + }); + assert.equal(schemaRefusal.result.isError, true); + control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + assert.equal(control.readAudit().length, before.audit); + assert.equal(control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }).replies.length, before.replies); + control.close(); + } finally { + await workerMcp.close(); + } + + const otherMcp = await connectMcp({ ...fixture, actorId: fixture.other }); + try { + const wrongActor = await otherMcp.request('tools/call', { + name: 'torch_reply_to_owner', arguments: { request_id: request.id, body: 'A valid identity cannot reply for another agent.' }, + }); + assert.equal(wrongActor.result.isError, true); + assert.match(wrongActor.result.content[0].text, /owner request addressed to the sender/i); + } finally { + await otherMcp.close(); + } + + control = openControlPlane({ repositoryRoot: fixture.root, env: fixture.env }); + assert.equal(control.readOwnerConversation({ actorId: 'owner', requestId: request.id, limit: 10 }).replies.length, 1); + control.close(); +});