Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 6200c87

Browse files
feat(local-mcp): enforce relay tool approval on the desktop
A relayed tools/call executes on the user's machine with their privileges, so CloudTaskService now refuses to execute one until the user approves it — enforced at the desktop, the real trust boundary, rather than trusting the sandbox's own prompt (a compromised sandbox could emit mcp_request events without asking) and adapter-neutrally (codex has no per-MCP-call approval hook, so a harness-side gate would leave GPT runs unguarded). The claude adapter still prompts via its always-ask path; to avoid a double prompt, the user's answer to that sandbox prompt is routed through sendCommand and converted into a consume-once pass (or run-scoped always-allow) for the matching relayed call. Denials return a JSON-RPC error with the user's feedback instead of executing. Approval state is evicted on terminal status.
1 parent 5eab279 commit 6200c87

3 files changed

Lines changed: 655 additions & 11 deletions

File tree

‎docs/cloud-mcp-relay.md‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,21 @@ const mcpResponseParamsSchema = z.object({
180180
A relayed tool call is the cloud agent executing something **on the user's
181181
machine or network with the user's local privileges**. Decisions:
182182

183-
1. **Always-ask by default.** Relayed servers' tools default to
184-
`needs_approval` through the existing MCP tool-approval mechanism
185-
(`McpToolApprovals`), regardless of the run's permission mode — an `auto`
186-
or `allow-all` cloud run still prompts (via the existing
187-
permission-request relay) before each relayed tool call. Users can
188-
allowlist individual tools, which persists like other tool approvals.
183+
1. **Always-ask, enforced on the desktop.** The desktop refuses to execute a
184+
relayed `tools/call` until the user has approved it, in
185+
`CloudTaskService.handleMcpRelayRequest`. This is the real trust boundary:
186+
the sandbox's own prompt can't be relied on (a compromised sandbox could
187+
emit `mcp_request` events without ever asking), and it is adapter-neutral —
188+
codex has no per-MCP-call approval hook, so a harness-side gate would leave
189+
GPT runs unguarded. The claude adapter *also* prompts (its always-ask
190+
`McpToolApprovals` path); to avoid a double prompt, the user's answer to
191+
that sandbox prompt is routed through `sendCommand`, where an allow is
192+
converted into a consume-once pass (or an always-allow, per run + server +
193+
tool) for the matching relayed call — so a harness that asks and one that
194+
doesn't both end at exactly one prompt. "Always allow" is scoped to the run
195+
and dropped when the run reaches a terminal status; a plain allow covers a
196+
single call with identical arguments. A denial (with optional feedback) is
197+
returned to the sandbox as a JSON-RPC error rather than executed.
189198
2. **No configuration crosses the wire.** The sandbox only ever names a
190199
server; what "grafana" means (command line, env, URL, headers) is resolved
191200
from local config on the desktop. A compromised sandbox cannot make the

‎packages/core/src/cloud-task/cloud-task.test.ts‎

Lines changed: 293 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3097,19 +3097,74 @@ describe("CloudTaskService MCP relay", () => {
30973097
requestId: string;
30983098
server: string;
30993099
expiresAt: string;
3100+
payload: Record<string, unknown>;
31003101
}> = {},
31013102
): string {
31023103
const event = {
31033104
type: "mcp_request",
31043105
requestId: overrides.requestId ?? "req-1",
31053106
server: overrides.server ?? "slack",
3106-
payload: { jsonrpc: "2.0", id: 1, method: "initialize" },
3107+
payload: overrides.payload ?? {
3108+
jsonrpc: "2.0",
3109+
id: 1,
3110+
method: "initialize",
3111+
},
31073112
expiresAt:
31083113
overrides.expiresAt ?? new Date(Date.now() + 60_000).toISOString(),
31093114
};
31103115
return `data: ${JSON.stringify(event)}\n\n`;
31113116
}
31123117

3118+
function toolsCallPayload(
3119+
args: Record<string, unknown> = { channel: "#general" },
3120+
): Record<string, unknown> {
3121+
return {
3122+
jsonrpc: "2.0",
3123+
id: 2,
3124+
method: "tools/call",
3125+
params: { name: "send_message", arguments: args },
3126+
};
3127+
}
3128+
3129+
function createControllableSseResponse(): {
3130+
response: Response;
3131+
push: (chunk: string) => void;
3132+
} {
3133+
const encoder = new TextEncoder();
3134+
let streamController: ReadableStreamDefaultController<Uint8Array>;
3135+
const stream = new ReadableStream<Uint8Array>({
3136+
start(controller) {
3137+
streamController = controller;
3138+
},
3139+
});
3140+
return {
3141+
response: new Response(stream, {
3142+
status: 200,
3143+
headers: { "Content-Type": "text/event-stream" },
3144+
}),
3145+
push: (chunk: string) => streamController.enqueue(encoder.encode(chunk)),
3146+
};
3147+
}
3148+
3149+
function lastPermissionRequestUpdate(
3150+
updates: unknown[],
3151+
): { requestId: string } | undefined {
3152+
return [...updates]
3153+
.reverse()
3154+
.find(
3155+
(u): u is { kind: string; requestId: string } =>
3156+
typeof u === "object" &&
3157+
u !== null &&
3158+
(u as { kind?: string }).kind === "permission_request",
3159+
);
3160+
}
3161+
3162+
function commandPosts(): unknown[] {
3163+
return mockNetFetch.mock.calls
3164+
.filter(([url]) => (url as string).includes("/command/"))
3165+
.map(([, init]) => JSON.parse((init as RequestInit).body as string));
3166+
}
3167+
31133168
function watchRun(runId: string): void {
31143169
mockNetFetch.mockResolvedValueOnce(
31153170
createJsonResponse({
@@ -3286,4 +3341,241 @@ describe("CloudTaskService MCP relay", () => {
32863341

32873342
expect(mcpRelayExecutor.closeRun).toHaveBeenCalledWith("run-1");
32883343
});
3344+
3345+
describe("relayed tools/call approval", () => {
3346+
it("prompts on the desktop and executes after the user allows", async () => {
3347+
const updates: unknown[] = [];
3348+
relayService.on(CloudTaskEvent.Update, (payload) => {
3349+
updates.push(payload);
3350+
});
3351+
mockNetFetch.mockResolvedValue(createJsonResponse({ result: {} }));
3352+
mockStreamFetch.mockResolvedValueOnce(
3353+
createOpenSseResponse(
3354+
mcpRequestSseLine({ payload: toolsCallPayload() }),
3355+
),
3356+
);
3357+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3358+
watchRun("run-1");
3359+
3360+
await waitFor(() => lastPermissionRequestUpdate(updates) !== undefined);
3361+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3362+
3363+
const prompt = lastPermissionRequestUpdate(updates);
3364+
await relayService.sendCommand({
3365+
taskId: "task-1",
3366+
runId: "run-1",
3367+
apiHost: "https://app.example.com",
3368+
teamId: 2,
3369+
method: "permission_response",
3370+
params: { requestId: prompt?.requestId, optionId: "allow" },
3371+
});
3372+
3373+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length > 0);
3374+
expect(mcpRelayExecutor.execute).toHaveBeenCalledWith(
3375+
"run-1",
3376+
"slack",
3377+
expect.objectContaining({ method: "tools/call" }),
3378+
);
3379+
await waitFor(() =>
3380+
commandPosts().some(
3381+
(body) => (body as { method?: string }).method === "mcp_response",
3382+
),
3383+
);
3384+
});
3385+
3386+
it("answers a denial to the sandbox without executing, carrying the user's feedback", async () => {
3387+
const updates: unknown[] = [];
3388+
relayService.on(CloudTaskEvent.Update, (payload) => {
3389+
updates.push(payload);
3390+
});
3391+
mockNetFetch.mockResolvedValue(createJsonResponse({ result: {} }));
3392+
mockStreamFetch.mockResolvedValueOnce(
3393+
createOpenSseResponse(
3394+
mcpRequestSseLine({ payload: toolsCallPayload() }),
3395+
),
3396+
);
3397+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3398+
watchRun("run-1");
3399+
3400+
await waitFor(() => lastPermissionRequestUpdate(updates) !== undefined);
3401+
const prompt = lastPermissionRequestUpdate(updates);
3402+
await relayService.sendCommand({
3403+
taskId: "task-1",
3404+
runId: "run-1",
3405+
apiHost: "https://app.example.com",
3406+
teamId: 2,
3407+
method: "permission_response",
3408+
params: {
3409+
requestId: prompt?.requestId,
3410+
optionId: "reject",
3411+
customInput: "use the announcements channel instead",
3412+
},
3413+
});
3414+
3415+
await waitFor(() =>
3416+
commandPosts().some(
3417+
(body) => (body as { method?: string }).method === "mcp_response",
3418+
),
3419+
);
3420+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3421+
const response = commandPosts().find(
3422+
(body) => (body as { method?: string }).method === "mcp_response",
3423+
) as { params: { error?: { message?: string } } };
3424+
expect(response.params.error?.message).toContain(
3425+
"use the announcements channel instead",
3426+
);
3427+
});
3428+
3429+
it("drops an unanswered prompt at the request's expiry without executing", async () => {
3430+
const updates: unknown[] = [];
3431+
relayService.on(CloudTaskEvent.Update, (payload) => {
3432+
updates.push(payload);
3433+
});
3434+
mockNetFetch.mockResolvedValue(createJsonResponse({ result: {} }));
3435+
mockStreamFetch.mockResolvedValueOnce(
3436+
createOpenSseResponse(
3437+
mcpRequestSseLine({
3438+
payload: toolsCallPayload(),
3439+
expiresAt: new Date(Date.now() + 150).toISOString(),
3440+
}),
3441+
),
3442+
);
3443+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3444+
watchRun("run-1");
3445+
3446+
await waitFor(() => lastPermissionRequestUpdate(updates) !== undefined);
3447+
await new Promise((resolve) => setTimeout(resolve, 300));
3448+
3449+
expect(mcpRelayExecutor.execute).not.toHaveBeenCalled();
3450+
expect(
3451+
commandPosts().some(
3452+
(body) => (body as { method?: string }).method === "mcp_response",
3453+
),
3454+
).toBe(false);
3455+
});
3456+
3457+
it("consumes a harness prompt approval instead of prompting a second time", async () => {
3458+
const updates: unknown[] = [];
3459+
relayService.on(CloudTaskEvent.Update, (payload) => {
3460+
updates.push(payload);
3461+
});
3462+
mockNetFetch.mockResolvedValue(createJsonResponse({ result: {} }));
3463+
const { response, push } = createControllableSseResponse();
3464+
mockStreamFetch.mockResolvedValueOnce(response);
3465+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3466+
watchRun("run-1");
3467+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3468+
3469+
// The harness (claude always-ask) prompt for the same call arrives first.
3470+
push(
3471+
`data: ${JSON.stringify({
3472+
type: "permission_request",
3473+
requestId: "harness-req-1",
3474+
toolCall: {
3475+
toolCallId: "tc-1",
3476+
title: "The agent wants to call send_message (slack)",
3477+
kind: "other",
3478+
rawInput: {
3479+
channel: "#general",
3480+
toolName: "mcp__slack__send_message",
3481+
},
3482+
},
3483+
options: [
3484+
{ kind: "allow_once", name: "Yes", optionId: "allow" },
3485+
{
3486+
kind: "allow_always",
3487+
name: "Yes, always allow",
3488+
optionId: "allow_always",
3489+
},
3490+
{ kind: "reject_once", name: "No", optionId: "reject" },
3491+
],
3492+
})}\n\n`,
3493+
);
3494+
await waitFor(() => lastPermissionRequestUpdate(updates) !== undefined);
3495+
3496+
// The user answers it in the task view; the response routes through
3497+
// sendCommand, granting a consume-once pass for the identical call.
3498+
await relayService.sendCommand({
3499+
taskId: "task-1",
3500+
runId: "run-1",
3501+
apiHost: "https://app.example.com",
3502+
teamId: 2,
3503+
method: "permission_response",
3504+
params: { requestId: "harness-req-1", optionId: "allow" },
3505+
});
3506+
3507+
const updatesBeforeCall = updates.length;
3508+
push(mcpRequestSseLine({ payload: toolsCallPayload() }));
3509+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length > 0);
3510+
3511+
// No desktop prompt was raised for the relayed call itself.
3512+
expect(
3513+
updates
3514+
.slice(updatesBeforeCall)
3515+
.filter(
3516+
(u) => (u as { kind?: string }).kind === "permission_request",
3517+
),
3518+
).toEqual([]);
3519+
3520+
// The pass was consume-once: an identical unattested call prompts again.
3521+
push(
3522+
mcpRequestSseLine({
3523+
requestId: "req-2",
3524+
payload: toolsCallPayload(),
3525+
}),
3526+
);
3527+
await waitFor(
3528+
() =>
3529+
updates
3530+
.slice(updatesBeforeCall)
3531+
.filter(
3532+
(u) => (u as { kind?: string }).kind === "permission_request",
3533+
).length > 0,
3534+
);
3535+
expect(mcpRelayExecutor.execute).toHaveBeenCalledOnce();
3536+
});
3537+
3538+
it("an always-allow answer covers subsequent calls to the same tool", async () => {
3539+
const updates: unknown[] = [];
3540+
relayService.on(CloudTaskEvent.Update, (payload) => {
3541+
updates.push(payload);
3542+
});
3543+
mockNetFetch.mockResolvedValue(createJsonResponse({ result: {} }));
3544+
const { response, push } = createControllableSseResponse();
3545+
mockStreamFetch.mockResolvedValueOnce(response);
3546+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3547+
watchRun("run-1");
3548+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3549+
3550+
push(mcpRequestSseLine({ payload: toolsCallPayload() }));
3551+
await waitFor(() => lastPermissionRequestUpdate(updates) !== undefined);
3552+
const prompt = lastPermissionRequestUpdate(updates);
3553+
await relayService.sendCommand({
3554+
taskId: "task-1",
3555+
runId: "run-1",
3556+
apiHost: "https://app.example.com",
3557+
teamId: 2,
3558+
method: "permission_response",
3559+
params: { requestId: prompt?.requestId, optionId: "allow_always" },
3560+
});
3561+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length === 1);
3562+
3563+
const updatesAfterFirst = updates.length;
3564+
// Different arguments — always-allow is per tool, not per exact call.
3565+
push(
3566+
mcpRequestSseLine({
3567+
requestId: "req-2",
3568+
payload: toolsCallPayload({ channel: "#random" }),
3569+
}),
3570+
);
3571+
await waitFor(() => mcpRelayExecutor.execute.mock.calls.length === 2);
3572+
expect(
3573+
updates
3574+
.slice(updatesAfterFirst)
3575+
.filter(
3576+
(u) => (u as { kind?: string }).kind === "permission_request",
3577+
),
3578+
).toEqual([]);
3579+
});
3580+
});
32893581
});

0 commit comments

Comments
 (0)