Skip to content

Commit 555b643

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 1efbb02 commit 555b643

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
@@ -3139,19 +3139,74 @@ describe("CloudTaskService MCP relay", () => {
31393139
requestId: string;
31403140
server: string;
31413141
expiresAt: string;
3142+
payload: Record<string, unknown>;
31423143
}> = {},
31433144
): string {
31443145
const event = {
31453146
type: "mcp_request",
31463147
requestId: overrides.requestId ?? "req-1",
31473148
server: overrides.server ?? "slack",
3148-
payload: { jsonrpc: "2.0", id: 1, method: "initialize" },
3149+
payload: overrides.payload ?? {
3150+
jsonrpc: "2.0",
3151+
id: 1,
3152+
method: "initialize",
3153+
},
31493154
expiresAt:
31503155
overrides.expiresAt ?? new Date(Date.now() + 60_000).toISOString(),
31513156
};
31523157
return `data: ${JSON.stringify(event)}\n\n`;
31533158
}
31543159

3160+
function toolsCallPayload(
3161+
args: Record<string, unknown> = { channel: "#general" },
3162+
): Record<string, unknown> {
3163+
return {
3164+
jsonrpc: "2.0",
3165+
id: 2,
3166+
method: "tools/call",
3167+
params: { name: "send_message", arguments: args },
3168+
};
3169+
}
3170+
3171+
function createControllableSseResponse(): {
3172+
response: Response;
3173+
push: (chunk: string) => void;
3174+
} {
3175+
const encoder = new TextEncoder();
3176+
let streamController: ReadableStreamDefaultController<Uint8Array>;
3177+
const stream = new ReadableStream<Uint8Array>({
3178+
start(controller) {
3179+
streamController = controller;
3180+
},
3181+
});
3182+
return {
3183+
response: new Response(stream, {
3184+
status: 200,
3185+
headers: { "Content-Type": "text/event-stream" },
3186+
}),
3187+
push: (chunk: string) => streamController.enqueue(encoder.encode(chunk)),
3188+
};
3189+
}
3190+
3191+
function lastPermissionRequestUpdate(
3192+
updates: unknown[],
3193+
): { requestId: string } | undefined {
3194+
return [...updates]
3195+
.reverse()
3196+
.find(
3197+
(u): u is { kind: string; requestId: string } =>
3198+
typeof u === "object" &&
3199+
u !== null &&
3200+
(u as { kind?: string }).kind === "permission_request",
3201+
);
3202+
}
3203+
3204+
function commandPosts(): unknown[] {
3205+
return mockNetFetch.mock.calls
3206+
.filter(([url]) => (url as string).includes("/command/"))
3207+
.map(([, init]) => JSON.parse((init as RequestInit).body as string));
3208+
}
3209+
31553210
function watchRun(runId: string): void {
31563211
mockNetFetch.mockResolvedValueOnce(
31573212
createJsonResponse({
@@ -3328,4 +3383,241 @@ describe("CloudTaskService MCP relay", () => {
33283383

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

0 commit comments

Comments
 (0)