Skip to content

Commit 0fe3606

Browse files
committed
Cover production inherit wrapping ungated MCP tools
Workers wrap inherited MCP handlers with the reactor-gated view. If the factory stored parent-gated tools, that outer wrap would still call the parent's requestApproval.
1 parent 13add2b commit 0fe3606

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

tests/integration/subagent-permission.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import {
1414
import { runSubAgent, type RunSubAgentParams } from "../../src/subagent/run.js";
1515
import { withMockedModuleDuring } from "../helpers/mock-module.js";
1616
import { mcpClientToAgentTools } from "../../src/mcp/plugin.js";
17+
import type { MCPClient } from "../../src/mcp/client.js";
1718
import { getSubAgentIdentity } from "../../src/subagent/identity-context.js";
1819
import { createSubAgentSessionStore } from "../../src/subagent/session-store.js";
20+
import { workerPermissionGate } from "../../src/permission/reactor-authorize.js";
21+
import { gateAgentTools } from "../../src/plugins/permission-plugin.js";
1922

2023
const report =
2124
"## Summary\nFinished.\n## Findings\nAttempted write.\n## Blockers\nNone.\n## Paths\nprobe.txt";
@@ -352,6 +355,184 @@ test.serial(
352355
20000,
353356
);
354357

358+
async function bindCreateAgentToolsetInherit(args: {
359+
cwd: string;
360+
permissionGate: PermissionGate;
361+
client: MCPClient;
362+
}): Promise<{
363+
inheritMcpTools: NonNullable<RunSubAgentParams["inheritMcpTools"]>;
364+
dispose: () => Promise<void>;
365+
}> {
366+
let inheritMcpTools: RunSubAgentParams["inheritMcpTools"];
367+
let dispose: () => Promise<void> = async () => undefined;
368+
await withMockedModuleDuring(
369+
import.meta.resolve("../../src/subagent/agent-fleet.js"),
370+
(real: typeof import("../../src/subagent/agent-fleet.js")) => ({
371+
...real,
372+
createSpawnAgentTool: (deps: Parameters<typeof real.createSpawnAgentTool>[0]) => {
373+
inheritMcpTools = deps.inheritMcpTools;
374+
return real.createSpawnAgentTool(deps);
375+
},
376+
}),
377+
async () =>
378+
withMockedModuleDuring(
379+
import.meta.resolve("../../src/mcp/client.js"),
380+
(real: typeof import("../../src/mcp/client.js")) => ({
381+
...real,
382+
connectMCPServer: async () => ({ ok: true as const, client: args.client }),
383+
}),
384+
async () => {
385+
const { createAgentToolset } = await import("../../src/agent/tools.js");
386+
const toolset = await createAgentToolset({
387+
cwd: args.cwd,
388+
permissionGate: args.permissionGate,
389+
onOperatorGate: async () => ({ kind: "cancel" }),
390+
mcpServers: [],
391+
subAgent: {
392+
provider: {
393+
providerName: "openai",
394+
baseURL: "https://api.openai.com/v1",
395+
model: "test-model",
396+
},
397+
getWorkdirBase: () => join(args.cwd, "state"),
398+
sessions: createSubAgentSessionStore(),
399+
},
400+
});
401+
dispose = () => toolset.dispose();
402+
await toolset.connectMCPServer(
403+
{ name: "probe", type: "http", url: "https://mcp.probe.test/mcp" },
404+
{
405+
interactiveAuth: false,
406+
onStatus: () => undefined,
407+
onToolsChanged: () => undefined,
408+
},
409+
);
410+
},
411+
),
412+
);
413+
if (inheritMcpTools === undefined) {
414+
await dispose();
415+
throw new Error("createAgentToolset did not wire inheritMcpTools");
416+
}
417+
return { inheritMcpTools, dispose };
418+
}
419+
420+
test.serial(
421+
"createAgentToolset inherit wraps ungated MCP tools with the passed worker gate",
422+
async () => {
423+
let asks = 0;
424+
await withWorker(
425+
async ({ cwd, harness, params, audit }) => {
426+
let calls = 0;
427+
const client = {
428+
serverName: "probe",
429+
tools: [
430+
{
431+
name: "mutate",
432+
description: "mutates",
433+
inputSchema: { type: "object", properties: {} },
434+
},
435+
],
436+
call: async () => {
437+
calls++;
438+
return "changed";
439+
},
440+
close: async () => undefined,
441+
};
442+
const bound = await bindCreateAgentToolsetInherit({
443+
cwd,
444+
permissionGate: params.permissionGate,
445+
client,
446+
});
447+
try {
448+
params.permissionGate.setSeededApprovals([
449+
{ tool: "mcp__probe__mutate", pattern: "mcp__probe__mutate" },
450+
]);
451+
const authorize = params.permissionGate.authorizeCall;
452+
params.permissionGate.authorizeCall = async (call) => {
453+
const result = await authorize(call);
454+
params.permissionGate.setSeededApprovals([]);
455+
return result;
456+
};
457+
params.inheritMcpTools = bound.inheritMcpTools;
458+
harness.scenario.replyOnce("openai", {
459+
toolCalls: [{ name: "mcp__probe__mutate", args: {} }],
460+
});
461+
harness.scenario.replyOnce("openai", { text: report });
462+
await Promise.all([runSubAgent(params), harness.run({ wallClockBudgetMs: 15000 })]);
463+
expect(calls).toBe(1);
464+
expect(asks).toBe(0);
465+
expect((await audit())[0]?.authz?.effect).toBe("allow");
466+
} finally {
467+
await bound.dispose();
468+
}
469+
},
470+
(cwd) =>
471+
createPermissionGate({
472+
cwd,
473+
approvals: [],
474+
interactive: true,
475+
auto: false,
476+
skipPermissions: false,
477+
reactorGated: false,
478+
requestApproval: async () => {
479+
asks++;
480+
return { allow: true };
481+
},
482+
}),
483+
);
484+
},
485+
20000,
486+
);
487+
488+
test("storing parent-gated MCP tools then wrapping again still calls requestApproval", async () => {
489+
const cwd = await mkdtemp(join(tmpdir(), "worker-permission-"));
490+
let asks = 0;
491+
let calls = 0;
492+
try {
493+
const parent = createPermissionGate({
494+
cwd,
495+
approvals: [],
496+
interactive: true,
497+
auto: false,
498+
skipPermissions: false,
499+
reactorGated: false,
500+
requestApproval: async () => {
501+
asks++;
502+
return { allow: true };
503+
},
504+
});
505+
const client = {
506+
serverName: "probe",
507+
tools: [
508+
{
509+
name: "mutate",
510+
description: "mutates",
511+
inputSchema: { type: "object", properties: {} },
512+
},
513+
],
514+
call: async () => {
515+
calls++;
516+
return "changed";
517+
},
518+
close: async () => undefined,
519+
};
520+
parent.registerMcpClient(client);
521+
const parentGated = mcpClientToAgentTools(client, parent);
522+
const doubleWrapped = gateAgentTools(parentGated, workerPermissionGate(parent));
523+
const tool = doubleWrapped[0];
524+
if (tool?.kind !== "full") throw new Error("expected full inherited MCP tool");
525+
await tool.handler(
526+
{ id: "c1", name: "mcp__probe__mutate", arguments: {} },
527+
new AbortController().signal,
528+
);
529+
expect(asks).toBe(1);
530+
expect(calls).toBe(1);
531+
} finally {
532+
await rm(cwd, { recursive: true, force: true });
533+
}
534+
});
535+
355536
test.serial(
356537
"live worker authorization is not evaluated again after policy revocation before runner",
357538
async () => {

0 commit comments

Comments
 (0)