diff --git a/shared/glean/mcp/src/skill-writer.ts b/shared/glean/mcp/src/skill-writer.ts index 42b0472..f943503 100644 --- a/shared/glean/mcp/src/skill-writer.ts +++ b/shared/glean/mcp/src/skill-writer.ts @@ -34,6 +34,33 @@ function parseFrontmatter(content: string): Record { return result; } +/** + * Keep approval requirements out of the local skill cache. The remote + * get_tool_approval lookup is the only source of truth, so a stale or hand-edited + * skill file must not retain a second approval setting for the plugin or the model + * to read. Other tool metadata, especially inputSchema, remains cached for argument + * shaping and prompt construction. + */ +function sanitizeSkillFile(filePath: string, text: string): string { + if (!/^tools[\\/]\S+\.json$/.test(filePath)) return text; + + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return text; + } + const { requires_approval: _ignored, ...metadata } = parsed as Record< + string, + unknown + >; + return JSON.stringify(metadata); + } catch { + // Leave malformed/non-object tool files alone; run_tool will not use them as + // approval state, and preserving the original content keeps diagnostics intact. + return text; + } +} + type LogFn = (label: string, detail?: Record) => void; /** @@ -99,8 +126,10 @@ export async function writeSkillsToDisk( continue; } await fs.mkdir(path.dirname(fullPath), { recursive: true }); - const text = - typeof content === "string" ? content : JSON.stringify(content); + const text = sanitizeSkillFile( + filePath, + typeof content === "string" ? content : JSON.stringify(content), + ); await fs.writeFile(fullPath, text, "utf-8"); writtenFiles.push(fullPath); } diff --git a/shared/glean/mcp/src/tools/run-tool.ts b/shared/glean/mcp/src/tools/run-tool.ts index 2436b6a..3862c5b 100644 --- a/shared/glean/mcp/src/tools/run-tool.ts +++ b/shared/glean/mcp/src/tools/run-tool.ts @@ -7,7 +7,6 @@ import os from "node:os"; import path from "node:path"; import { callRemoteTool } from "../remote-client.js"; import { FILE_ARGS_DISABLED_TEXT } from "../policy/enforce.js"; -import { buildCompactArgs, writeApprovalArgsFile } from "./approval-args.js"; import { resolveSessionId } from "../session-id.js"; import { hostSharedDataDir } from "../data-dir.js"; @@ -160,7 +159,6 @@ export async function resolveFileArgs( } interface ToolMetadata { - requires_approval?: boolean; name?: string; description?: string; server_id?: string; @@ -198,35 +196,61 @@ export function isCursorClient(mcpServer: Server): boolean { .startsWith("cursor"); } -// Plain text, NOT Markdown: every host, including Cursor, gets the action and -// arguments in the elicitation itself. Depending on a host to render them above -// the prompt left Cursor's review text pointing at content that no longer -// appeared in newer builds. -async function buildApprovalMessage( - toolName: string, - args: unknown, -): Promise { - const { lines, needsFile } = buildCompactArgs(args); - // Indent argument lines under "Arguments:" so the structural labels stay - // distinct from values; keys are uppercased (in compactArgLine) so a key - // reads distinctly from its value — plain-text cues that cost no vertical - // space. - const message = [ - `Action: ${toolName}`, - "Arguments:", - ...lines.map((line) => ` ${line}`), - ]; - if (needsFile) { - // Best-effort: a failed spill (e.g. a sandbox blocking writes outside the - // project dir) must never break the approval gate, so fall back to a note. - try { - const filePath = await writeApprovalArgsFile(toolName, args); - message.push(` Full arguments: ${filePath}`); - } catch { - message.push(" (some arguments truncated; full-args file unavailable)"); - } +// Keep this form aligned with Scio's run_tool approval UX: one required enum, +// with Always Allow first and selected by default. +const approvalField = "approval"; +const approvalAlwaysAllow = "Always Allow"; +const approvalAllow = "Allow"; +const approvalDeny = "Deny"; +const approvalCancel = "cancel"; +const approvalChoices = [ + approvalAlwaysAllow, + approvalAllow, + approvalDeny, +] as const; +type ApprovalChoice = (typeof approvalChoices)[number]; +type ApprovalDecision = ApprovalChoice | typeof approvalCancel; + +function runToolApprovalForm(toolName: string) { + return { + mode: "form" as const, + message: + `Allow running the write tool ${toolName}?\n\n` + + `Always Allow is selected by default. Accepting with this selection ` + + `saves approval for future calls to this tool. To change it, select a ` + + `different Approval option below.`, + requestedSchema: { + type: "object", + required: [approvalField], + properties: { + [approvalField]: { + type: "string", + title: "Approval", + description: `Whether to run ${toolName}.`, + enum: [...approvalChoices], + default: approvalChoices[0], + }, + }, + } as any, + }; +} + +function approvalDecision(result: { + action: string; + content?: unknown; +}): ApprovalDecision | null { + if (result.action === "decline") return approvalDeny; + if (result.action === "cancel") return approvalCancel; + if (result.action !== "accept") return null; + if ( + typeof result.content !== "object" || + result.content === null || + Array.isArray(result.content) + ) { + return null; } - return message.join("\n"); + const choice = (result.content as Record)[approvalField]; + return approvalChoices.find((candidate) => candidate === choice) ?? null; } // A WeakSet so a short-lived server in tests doesn't leak, @@ -312,6 +336,91 @@ export interface RunToolPolicy { fileArgs: boolean; } +class ToolApprovalError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolApprovalError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function approvalResponsePayload(result: CallToolResult): unknown { + const structured = (result as CallToolResult & { + structuredContent?: unknown; + }).structuredContent; + if (structured !== undefined) return structured; + + const text = result.content.find((item) => item.type === "text"); + if (!text || text.type !== "text") return undefined; + try { + return JSON.parse(text.text); + } catch { + return undefined; + } +} + +/** + * Ask the remote control plane whether this downstream tool requires approval. + * + * This is deliberately a per-call lookup. The answer is not read from skill files, + * stored in this process, or persisted locally. A missing, malformed, or failed + * response fails closed so the downstream `run_tool` call cannot proceed without a + * current remote decision. + */ +export async function getToolApproval( + remoteClient: Client, + serverId: string, + toolName: string, +): Promise { + let result: CallToolResult; + try { + result = await callRemoteTool(remoteClient, "get_tool_approval", { + server_id: serverId, + tool_name: toolName, + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new ToolApprovalError(`remote lookup failed: ${detail}`); + } + + if (result.isError) { + const text = result.content.find((item) => item.type === "text"); + const detail = text?.type === "text" ? text.text : "remote lookup returned an error"; + throw new ToolApprovalError(detail); + } + + const payload = approvalResponsePayload(result); + if (!isRecord(payload) || typeof payload.requires_approval !== "boolean") { + throw new ToolApprovalError( + "remote response did not contain boolean requires_approval", + ); + } + return payload.requires_approval; +} + +function approvalLookupFailure( + toolName: string, + error: unknown, +): CallToolResult { + const detail = error instanceof Error ? error.message : String(error); + console.error(`[get_tool_approval] ${toolName}: ${detail}`); + return { + content: [ + { + type: "text", + text: + `Could not determine whether ${toolName} requires approval from the ` + + `remote settings. The action was NOT executed. Retry when the approval ` + + `settings are available.`, + }, + ], + isError: true, + }; +} + export async function handleRunTool( remoteClient: Client, mcpServer: Server, @@ -331,9 +440,9 @@ export async function handleRunTool( }; } - // Load the downstream tool's metadata once, up front: its inputSchema drives - // file_args JSON-parsing (object/array params) and its requires_approval - // drives the HITL gate. Both paths must see it regardless of ENABLE_HITL. + // Load the downstream tool's metadata only for inputSchema. Approval is not + // taken from this file; it is fetched from the remote control plane below for + // every attempted downstream call. const toolMeta = await findToolJson(skillsBaseDir, toolName); // Refuse before reading any model-supplied path. Disabled file_args must be @@ -345,9 +454,8 @@ export async function handleRunTool( }; } - // Resolve file_args up front so the approval prompt shows the COMPLETE input - // (file-sourced values included, not just the inline `arguments`), and so an - // unreadable file_args path fails before we prompt the user. + // Resolve file_args before approval so the approved call uses the complete + // input and an unreadable model-supplied path fails before we prompt the user. const baseArgs = args.arguments != null && typeof args.arguments === "object" ? (args.arguments as Record) @@ -369,18 +477,14 @@ export async function handleRunTool( throw err; } + let requiresApproval: boolean; + try { + requiresApproval = await getToolApproval(remoteClient, serverId, toolName); + } catch (err) { + return approvalLookupFailure(toolName, err); + } + const hitlEnabled = process.env.ENABLE_HITL === "true"; - // Fail CLOSED when the tool's approval requirement is unknown. The gate used - // to key on `toolMeta?.requires_approval`; a missing or unparseable tool JSON - // (evicted by evictStaleSkills after a week, called from memory without a - // fresh find_skills_and_tools, or corrupt) made that falsy, so the gate - // was skipped and — with the native prompt already suppressed via - // readOnlyHint — the tool executed with ZERO approval. Only skip the gate - // when we can positively confirm the tool is read-only. - const requiresApproval = - typeof toolMeta?.requires_approval === "boolean" - ? toolMeta.requires_approval - : true; // Cursor is deliberately not excepted: current Cursor builds can use the same // local elicitation gate as other capable hosts. Older builds that drop the // prompt fail closed, and the timeout response explains the upgrade path. @@ -398,7 +502,6 @@ export async function handleRunTool( // gate. Only bypassPermissions is skipped (deliberately narrow). const bypass = (await currentPermissionMode()) === "bypassPermissions"; if (!bypass) { - const message = await buildApprovalMessage(toolName, resolvedArgs); const timeout = hitlTimeoutMs(); // Make a dummy empty request to burn JSON-RPC request id 0 @@ -407,23 +510,49 @@ export async function handleRunTool( const startedAt = Date.now(); try { const result = await mcpServer.elicitInput( - { - message, - requestedSchema: { type: "object", properties: {} } as any, - }, + runToolApprovalForm(toolName), { timeout }, ); + const decision = approvalDecision(result); - if (result.action !== "accept") { + if (decision === approvalDeny || decision === approvalCancel) { + return { + content: [ + { + type: "text", + text: `Action ${toolName} was ${decision === approvalDeny ? "declined" : "cancelled"} by the user.`, + }, + ], + }; + } + if (decision === null) { return { content: [ { type: "text", - text: `Action ${toolName} was ${result.action === "decline" ? "declined" : "cancelled"} by the user.`, + text: + `Action ${toolName} was not approved — the approval form ` + + `response was invalid. The action was NOT executed.`, }, ], + isError: true, }; } + + if (decision === approvalAlwaysAllow) { + try { + await callRemoteTool(remoteClient, "set_tool_approval", { + server_id: serverId, + tool_name: toolName, + value: "ALWAYS_ALLOWED", + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.error( + `[set_tool_approval] failed to persist "${toolName}" to Glean: ${detail}`, + ); + } + } } catch (err) { // Fail CLOSED. An approval gate that executes the action when the // prompt times out or errors defeats its own purpose — and the SDK diff --git a/shared/glean/mcp/tests/run-tool.test.ts b/shared/glean/mcp/tests/run-tool.test.ts index e1ef2a4..3994923 100644 --- a/shared/glean/mcp/tests/run-tool.test.ts +++ b/shared/glean/mcp/tests/run-tool.test.ts @@ -6,6 +6,7 @@ import { resolveFileArgs, buildRemoteArgs, FileArgsError, + getToolApproval, handleRunTool, runToolAnnotations, elicitationFailureText, @@ -248,15 +249,54 @@ describe("buildRemoteArgs", () => { }); }); -function makeRemote() { +function makeRemote(opts: { + requiresApproval?: boolean; + approvalResult?: unknown; + approvalError?: Error; +} = {}) { + const downstreamCall = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + }); + const callTool = vi.fn().mockImplementation(async (request: { name: string }) => { + if (request.name === "get_tool_approval") { + if (opts.approvalError) throw opts.approvalError; + return opts.approvalResult ?? { + content: [ + { + type: "text", + text: JSON.stringify({ + requires_approval: opts.requiresApproval ?? true, + }), + }, + ], + }; + } + return downstreamCall(request); + }); return { - callTool: vi.fn().mockResolvedValue({ - content: [{ type: "text", text: "ok" }], - }), + callTool, + downstreamCall, close: vi.fn(), } as any; } +function approvalResult(choice: "Always Allow" | "Allow" | "Deny") { + return { action: "accept", content: { approval: choice } }; +} + +function allowOnce() { + return vi.fn().mockResolvedValue(approvalResult("Allow")); +} + +function expectedApprovalMessage(toolName: string): string { + return ( + `Allow running the write tool ${toolName}?\n\n` + + `Always Allow is selected by default. Accepting with this selection ` + + `saves approval for future calls to this tool. To change it, select a ` + + `different Approval option below.` + ); +} + function makeServer(opts: { elicitation?: boolean; clientName?: string; @@ -270,7 +310,7 @@ function makeServer(opts: { getClientVersion: vi .fn() .mockReturnValue({ name: opts.clientName ?? "claude-code", version: "1" }), - elicitInput: opts.elicit ?? vi.fn().mockResolvedValue({ action: "accept" }), + elicitInput: opts.elicit ?? allowOnce(), // Used by primeElicitationCancellation to burn request id 0. request: opts.request ?? vi.fn().mockResolvedValue({}), } as any; @@ -306,6 +346,21 @@ async function writeModeMarker( ); } +describe("getToolApproval", () => { + it("accepts a structured remote response", async () => { + const remote = makeRemote({ + approvalResult: { + content: [], + structuredContent: { requires_approval: true }, + }, + }); + + await expect( + getToolApproval(remote, "server-1", "tool-1"), + ).resolves.toBe(true); + }); +}); + describe("handleRunTool (HITL)", () => { let tmpDir: string; const baseArgs = { @@ -332,49 +387,25 @@ describe("handleRunTool (HITL)", () => { await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); expect(server.elicitInput).not.toHaveBeenCalled(); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); - it("does not elicit when the tool does not require approval", async () => { + it("does not elicit when the remote says the tool does not require approval", async () => { vi.stubEnv("ENABLE_HITL", "true"); - const remote = makeRemote(); + const remote = makeRemote({ requiresApproval: false }); const server = makeServer({ elicitation: true }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: false }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); expect(server.elicitInput).not.toHaveBeenCalled(); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); - it("fails closed when the tool's approval requirement is unknown", async () => { + it("keeps model-supplied arguments out of approval form labels", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); - const server = makeServer({ elicitation: true, elicit }); - - await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - - expect(elicit).toHaveBeenCalledTimes(1); - expect(remote.callTool).toHaveBeenCalledTimes(1); - }); - - it("does not execute unknown-approval tools when the user declines", async () => { - vi.stubEnv("ENABLE_HITL", "true"); - const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "decline" }); - const server = makeServer({ elicitation: true, elicit }); - - await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - - expect(elicit).toHaveBeenCalledTimes(1); - expect(remote.callTool).not.toHaveBeenCalled(); - }); - - it("sanitizes argument keys so newlines cannot forge prompt labels", async () => { - vi.stubEnv("ENABLE_HITL", "true"); - const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); @@ -385,22 +416,22 @@ describe("handleRunTool (HITL)", () => { { server_id: "s", tool_name: "jirasearch", - arguments: { "note\nACTION: read_only_lookup": "x" }, + arguments: { "note\nAPPROVAL: Always Allow": "x" }, }, ALL_ON, ); - const message = elicit.mock.calls[0][0].message as string; - expect(message).not.toMatch(/^\s*ACTION: READ_ONLY_LOOKUP/m); - expect( - message.split("\n").filter((line) => line.startsWith("Action:")), - ).toHaveLength(1); + const params = elicit.mock.calls[0][0]; + expect(params.message).toBe(expectedApprovalMessage("jirasearch")); + expect(JSON.stringify(params.requestedSchema)).not.toContain( + "note\\nAPPROVAL", + ); }); it("DOES elicit for Cursor — our prompt is the single gate there too", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, clientName: "cursor-vscode", @@ -410,19 +441,15 @@ describe("handleRunTool (HITL)", () => { await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - // Cursor is no longer excluded: it gets readOnlyHint like every other - // elicitation-capable host, so this prompt is the only approval gate. + // Cursor gets the same single approval form as other hosts. expect(elicit).toHaveBeenCalledTimes(1); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); - // Cursor used to render the tool and its arguments itself, so its prompt was only a - // review ask pointing at them. It stopped doing that (confirmed by screenshot, Aug - // 2026), so it now gets the same self-describing text as every other host. - it("spells out action and arguments for Cursor too, since it no longer shows them", async () => { + it("renders the same approval form for Cursor", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, clientName: "cursor-vscode", @@ -430,34 +457,40 @@ describe("handleRunTool (HITL)", () => { }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); - await handleRunTool(remote, server, tmpDir, { - ...baseArgs, - arguments: { project: "ENG", summary: "ship it" }, - }, ALL_ON); + await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - const message = elicit.mock.calls[0][0].message as string; - expect(message).toContain("Action: jirasearch"); - expect(message).toContain("ENG"); - // Would point at something Cursor no longer draws. - expect(message).not.toContain("shown above"); + const params = elicit.mock.calls[0][0]; + expect(params.message).toBe(expectedApprovalMessage("jirasearch")); + expect(params.requestedSchema.properties.approval.title).toBe("Approval"); }); - it("spells out action and arguments for a host that does not render them", async () => { + it("offers a required Approval enum with Always Allow selected by default", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); - await handleRunTool(remote, server, tmpDir, { - ...baseArgs, - arguments: { project: "ENG" }, - }, ALL_ON); + await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - const message = elicit.mock.calls[0][0].message as string; - expect(message).toContain("Action: jirasearch"); - expect(message).toContain("ENG"); - expect(message).not.toContain("shown above"); + expect(elicit).toHaveBeenCalledTimes(1); + expect(elicit.mock.calls[0][0]).toEqual({ + mode: "form", + message: expectedApprovalMessage("jirasearch"), + requestedSchema: { + type: "object", + required: ["approval"], + properties: { + approval: { + type: "string", + title: "Approval", + description: "Whether to run jirasearch.", + enum: ["Always Allow", "Allow", "Deny"], + default: "Always Allow", + }, + }, + }, + }); }); // Cursor's pre-3.15 bug can drop the prompt, so the request burns the whole @@ -486,7 +519,7 @@ describe("handleRunTool (HITL)", () => { expect(result.isError).toBe(true); expect(text).toContain("3.15"); expect(text).toContain("NOT executed"); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); }); // A timeout cannot distinguish "prompt shown, nobody answered" from "prompt never @@ -546,7 +579,7 @@ describe("handleRunTool (HITL)", () => { expect(result.isError).toBe(true); expect(text).not.toContain("3.15"); expect(text).toContain("Ask the user to confirm"); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); }); it("never mentions Cursor to another host, even on a full-timeout hang", async () => { @@ -565,7 +598,7 @@ describe("handleRunTool (HITL)", () => { const result = await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); expect((result.content[0] as { text: string }).text).not.toContain("3.15"); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); }); // fileArgs disabled by remote policy. The refusal lives here rather than at the call @@ -625,7 +658,7 @@ describe("handleRunTool (HITL)", () => { }); expect(result.isError).toBeUndefined(); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); it("treats a spec-compliant cancel as a cancel, not a failure", async () => { @@ -645,29 +678,26 @@ describe("handleRunTool (HITL)", () => { expect(text).toContain("cancelled by the user"); expect(text).not.toContain("3.15"); expect(result.isError).toBeUndefined(); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); }); - it("prompts with action name + arguments and forwards on accept", async () => { + it("forwards exactly once when the form choice is Allow", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); - await writeToolJson(tmpDir, "jirasearch", { - requires_approval: true, - description: "Search Jira issues", - }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); const [params, options] = elicit.mock.calls[0]; - expect(params.message).toContain("Action: jirasearch"); - expect(params.message).toContain("PROJECT: ABC"); - expect(params.message).not.toContain("Server:"); - expect(params.message).not.toContain("Search Jira issues"); - expect(params.message).not.toContain("**"); + expect(params.message).toBe(expectedApprovalMessage("jirasearch")); expect(options.timeout).toBe(300_000); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + "run_tool", + ]); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); it("pings to burn request id 0 before the first elicitation (so timeout cancellation is honored), once per server", async () => { @@ -677,7 +707,7 @@ describe("handleRunTool (HITL)", () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); const request = vi.fn().mockResolvedValue({}); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = vi.fn().mockResolvedValue(approvalResult("Allow")); const server = makeServer({ elicitation: true, elicit, request }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); @@ -687,13 +717,12 @@ describe("handleRunTool (HITL)", () => { // Ping fired exactly once for this server, and it is a ping. expect(request).toHaveBeenCalledTimes(1); expect(request.mock.calls[0][0]).toEqual({ method: "ping" }); - // Both prompts still ran. expect(elicit).toHaveBeenCalledTimes(2); }); - it("does not ping when the tool requires no approval (no elicitation)", async () => { + it("does not ping when the remote says the tool requires no approval", async () => { vi.stubEnv("ENABLE_HITL", "true"); - const remote = makeRemote(); + const remote = makeRemote({ requiresApproval: false }); const request = vi.fn().mockResolvedValue({}); const server = makeServer({ elicitation: true, request }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: false }); @@ -707,7 +736,7 @@ describe("handleRunTool (HITL)", () => { vi.stubEnv("ENABLE_HITL", "true"); vi.stubEnv("HITL_TIMEOUT_MS", "5000"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); @@ -723,7 +752,7 @@ describe("handleRunTool (HITL)", () => { for (const bad of ["0", "-1", "abc", ""]) { vi.stubEnv("HITL_TIMEOUT_MS", bad); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); @@ -741,7 +770,7 @@ describe("handleRunTool (HITL)", () => { const result = await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); expect((result.content[0] as { text: string }).text).toContain("declined"); }); @@ -754,17 +783,17 @@ describe("handleRunTool (HITL)", () => { const result = await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - expect(remote.callTool).not.toHaveBeenCalled(); + expect(remote.downstreamCall).not.toHaveBeenCalled(); expect(result.isError).toBe(true); const text = (result.content[0] as { text: string }).text; expect(text).toContain("not approved"); expect(text).toContain("NOT executed"); }); - it("spills large arguments to a file and keeps the prompt short", async () => { + it("does not embed large model-supplied arguments in the approval form", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await writeToolJson(tmpDir, "create_doc", { requires_approval: true }); @@ -775,27 +804,16 @@ describe("handleRunTool (HITL)", () => { arguments: { title: "Report", body: bigBody }, }, ALL_ON); - const message = elicit.mock.calls[0][0].message as string; - expect(message).toContain("Action: create_doc"); - expect(message).toContain("TITLE: Report"); - expect(message.split("\n").length).toBeLessThanOrEqual(10); - - const fileLine = message - .split("\n") - .find((l) => l.includes("Full arguments: ")); - expect(fileLine).toBeDefined(); - const marker = "Full arguments: "; - const filePath = fileLine!.slice(fileLine!.indexOf(marker) + marker.length).trim(); - const fileContent = await fs.readFile(filePath, "utf-8"); - expect(fileContent).toContain(bigBody); - expect(fileContent).toContain("## body"); - await fs.rm(filePath, { force: true }); + const params = elicit.mock.calls[0][0]; + expect(params.message).toBe(expectedApprovalMessage("create_doc")); + expect(JSON.stringify(params.requestedSchema)).not.toContain(bigBody); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); - it("surfaces file_args content in the approval prompt", async () => { + it("resolves file_args before approval and forwards them after Allow", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await writeToolJson(tmpDir, "create_doc", { requires_approval: true }); const bodyFile = path.join(tmpDir, "draft.md"); @@ -808,10 +826,13 @@ describe("handleRunTool (HITL)", () => { file_args: { body: bodyFile }, }, ALL_ON); - const message = elicit.mock.calls[0][0].message as string; - expect(message).toContain("TITLE: Doc"); - expect(message).toContain("BODY: FILE_SOURCED_BODY"); // file-sourced arg shown - expect(remote.callTool).toHaveBeenCalledTimes(1); // executed on accept + expect(elicit.mock.calls[0][0].message).toBe( + expectedApprovalMessage("create_doc"), + ); + expect(remote.downstreamCall.mock.calls[0][0].arguments.arguments).toEqual({ + title: "Doc", + body: "FILE_SOURCED_BODY", + }); }); it("parses an object-typed file_arg from the tool schema and forwards it as structured data", async () => { @@ -832,7 +853,7 @@ describe("handleRunTool (HITL)", () => { file_args: { spec: specFile }, }, ALL_ON); - const call = remote.callTool.mock.calls[0][0]; + const call = remote.downstreamCall.mock.calls[0][0]; expect(call.name).toBe("run_tool"); expect(call.arguments.arguments.spec).toEqual({ name: "my-agent", @@ -859,6 +880,165 @@ describe("handleRunTool (HITL)", () => { expect(remote.callTool).not.toHaveBeenCalled(); }); + it("uses the remote approval result on every attempted downstream call", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote({ requiresApproval: false }); + const server = makeServer({ elicitation: true }); + // This stale local value must not affect the remote-only decision. + await writeToolJson(tmpDir, "remote_only_tool", { requires_approval: true }); + const args = { ...baseArgs, tool_name: "remote_only_tool" }; + + await handleRunTool(remote, server, tmpDir, args, ALL_ON); + await handleRunTool(remote, server, tmpDir, args, ALL_ON); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + "run_tool", + "get_tool_approval", + "run_tool", + ]); + expect(remote.callTool.mock.calls[0][0].arguments).toEqual({ + server_id: baseArgs.server_id, + tool_name: "remote_only_tool", + }); + }); + + it("prompts when the remote requires approval even if local metadata says false", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote({ requiresApproval: true }); + const elicit = allowOnce(); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "remote_required_tool", { requires_approval: false }); + + await handleRunTool( + remote, + server, + tmpDir, + { ...baseArgs, tool_name: "remote_required_tool" }, + ALL_ON, + ); + + expect(elicit).toHaveBeenCalledTimes(1); + expect(elicit.mock.calls[0][0].requestedSchema.properties.approval.enum).toEqual([ + "Always Allow", + "Allow", + "Deny", + ]); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + "run_tool", + ]); + }); + + it("persists an explicit always-allow decision before running the tool", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi + .fn() + .mockResolvedValue(approvalResult("Always Allow")); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "always_tool", { requires_approval: true }); + + await handleRunTool( + remote, + server, + tmpDir, + { ...baseArgs, tool_name: "always_tool" }, + ALL_ON, + ); + + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + "set_tool_approval", + "run_tool", + ]); + expect(remote.callTool.mock.calls[1][0].arguments).toEqual({ + server_id: baseArgs.server_id, + tool_name: "always_tool", + value: "ALWAYS_ALLOWED", + }); + }); + + it("does not execute when the accepted form choice is Deny", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue(approvalResult("Deny")); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "denied_tool", { requires_approval: true }); + + const result = await handleRunTool( + remote, + server, + tmpDir, + { ...baseArgs, tool_name: "denied_tool" }, + ALL_ON, + ); + + expect((result.content[0] as { text: string }).text).toContain("declined"); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + ]); + }); + + it("fails closed when an accepted form response is missing its approval choice", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept", content: {} }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "malformed_tool", { requires_approval: true }); + + const result = await handleRunTool( + remote, + server, + tmpDir, + { ...baseArgs, tool_name: "malformed_tool" }, + ALL_ON, + ); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + "approval form response was invalid", + ); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + ]); + }); + + it("fails closed when the remote approval lookup is malformed", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote({ + approvalResult: { content: [{ type: "text", text: "{}" }] }, + }); + const server = makeServer({ elicitation: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + "requires approval", + ); + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool.mock.calls.map((c: any) => c[0].name)).toEqual([ + "get_tool_approval", + ]); + }); + + it("fails closed when the remote approval lookup errors", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote({ approvalError: new Error("503 unavailable") }); + const server = makeServer({ elicitation: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + "The action was NOT executed", + ); + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + it("skips the elicitation gate and executes directly in bypassPermissions mode", async () => { vi.stubEnv("ENABLE_HITL", "true"); vi.stubEnv("CLAUDE_PLUGIN_DATA", tmpDir); @@ -872,7 +1052,7 @@ describe("handleRunTool (HITL)", () => { await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); expect(elicit).not.toHaveBeenCalled(); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); it("still elicits when the session's permission mode is not bypass", async () => { @@ -882,13 +1062,13 @@ describe("handleRunTool (HITL)", () => { await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); await writeModeMarker(tmpDir, "sess-default", "default"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); expect(elicit).toHaveBeenCalledTimes(1); - expect(remote.callTool).toHaveBeenCalledTimes(1); + expect(remote.downstreamCall).toHaveBeenCalledTimes(1); }); it("still elicits when no permission-mode marker exists (fails toward the gate)", async () => { @@ -898,7 +1078,7 @@ describe("handleRunTool (HITL)", () => { await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); // Deliberately write no marker. const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); @@ -914,12 +1094,12 @@ describe("handleRunTool (HITL)", () => { // Another concurrent session opted into bypass; ours did not. await writeModeMarker(tmpDir, "sess-B", "bypassPermissions"); const remote = makeRemote(); - const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const elicit = allowOnce(); const server = makeServer({ elicitation: true, elicit }); await handleRunTool(remote, server, tmpDir, baseArgs, ALL_ON); - expect(elicit).toHaveBeenCalledTimes(1); // gate preserved for THIS session + expect(elicit).toHaveBeenCalledTimes(1); // one form carries all choices }); }); diff --git a/shared/glean/mcp/tests/skill-writer.test.ts b/shared/glean/mcp/tests/skill-writer.test.ts index f70a8a0..2d85ead 100644 --- a/shared/glean/mcp/tests/skill-writer.test.ts +++ b/shared/glean/mcp/tests/skill-writer.test.ts @@ -61,6 +61,28 @@ describe("writeSkillsToDisk", () => { expect(toolJson.input_schema.properties.query.type).toBe("string"); }); + it("does not persist local approval requirements in tool metadata", async () => { + const skills: SkillsMap = { + "remote-approval": { + "tools/action.json": JSON.stringify({ + requires_approval: true, + inputSchema: { properties: { title: { type: "string" } } }, + }), + }, + }; + + await writeSkillsToDisk(skills, tmpDir); + + const toolJson = JSON.parse( + await fs.readFile( + path.join(tmpDir, "remote-approval", "tools", "action.json"), + "utf-8", + ), + ); + expect(toolJson.requires_approval).toBeUndefined(); + expect(toolJson.inputSchema.properties.title.type).toBe("string"); + }); + it("creates nested directories from slash-separated paths", async () => { const skills: SkillsMap = { "code-review": {