diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index e0bda4d..6514bba 100644 --- a/plugins/glean/.claude-plugin/plugin.json +++ b/plugins/glean/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.33", + "version": "0.2.34", "description": "Glean plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.codex-plugin/plugin.json b/plugins/glean/.codex-plugin/plugin.json index f358af1..2e18a5a 100644 --- a/plugins/glean/.codex-plugin/plugin.json +++ b/plugins/glean/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.33", + "version": "0.2.34", "description": "Glean Codex plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.cursor-plugin/plugin.json b/plugins/glean/.cursor-plugin/plugin.json index a3428e7..d197d8e 100644 --- a/plugins/glean/.cursor-plugin/plugin.json +++ b/plugins/glean/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "glean-vnext", "displayName": "Glean vNext", - "version": "0.2.33", + "version": "0.2.34", "description": "Search and act across your company's apps — Jira, Slack, Salesforce, Google Workspace, and more — without leaving Cursor.", "author": { "name": "Glean" diff --git a/plugins/glean/hooks/auto-approve-run-tool.mjs b/plugins/glean/hooks/auto-approve-run-tool.mjs new file mode 100644 index 0000000..1ef1585 --- /dev/null +++ b/plugins/glean/hooks/auto-approve-run-tool.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// PreToolUse hook for Claude Code. +// +// When HITL is enabled, run_tool is gated by the plugin's own elicitation +// prompt, so Claude Code's separate native "allow this tool?" prompt is +// redundant (the double-prompt). With ENABLE_HITL=true we auto-approve the +// run_tool call, leaving the HITL elicitation as the single gate. +// +// Safety: run_tool is read-only ONLY while HITL gates it. This hook runs only +// under Claude Code, which always advertises the elicitation capability, so +// ENABLE_HITL=true means run_tool's HITL prompt is active — never an ungated +// write. When ENABLE_HITL is not "true" the hook does nothing and the normal +// permission flow runs. +import fs from "node:fs"; +import path from "node:path"; + +function readStdin() { + try { + return fs.readFileSync(0, "utf-8"); + } catch { + return ""; + } +} + +let input = {}; +try { + input = JSON.parse(readStdin()); +} catch { + // Malformed/empty input: do nothing, let the normal permission flow run. +} + +const toolName = String(input.tool_name ?? ""); +const bareName = toolName.split("__").pop() ?? ""; +// Scope strictly to this plugin's run_tool — the tool name carries the glean +// plugin/server prefix (e.g. mcp__plugin_glean-vnext_glean__run_tool). +if (!toolName.includes("glean") || bareName !== "run_tool") { + process.exit(0); +} + +// The hook process does not inherit the MCP server's env, so read the flag +// from the plugin's own .mcp.json. +let env = {}; +try { + const root = process.env.CLAUDE_PLUGIN_ROOT ?? "."; + const cfg = JSON.parse(fs.readFileSync(path.join(root, ".mcp.json"), "utf-8")); + env = cfg?.mcpServers?.glean?.env ?? {}; +} catch { + // No readable config: do nothing. +} + +if (env.ENABLE_HITL === "true") { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: + "Glean run_tool is gated by its own HITL elicitation prompt; suppressing the redundant native prompt while ENABLE_HITL is on.", + }, + }), + ); +} +process.exit(0); diff --git a/plugins/glean/hooks/hooks.json b/plugins/glean/hooks/hooks.json new file mode 100644 index 0000000..f135e7b --- /dev/null +++ b/plugins/glean/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__.*glean.*run_tool", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/auto-approve-run-tool.mjs\"" + } + ] + } + ] + } +} diff --git a/tests/auto-approve-hook.test.ts b/tests/auto-approve-hook.test.ts new file mode 100644 index 0000000..fbe7cf1 --- /dev/null +++ b/tests/auto-approve-hook.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const HOOK = path.resolve( + here, + "../plugins/glean/hooks/auto-approve-run-tool.mjs", +); + +async function runHook( + toolName: string, + env: Record, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "approve-hook-")); + await fs.writeFile( + path.join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { glean: { env } } }), + ); + try { + return await new Promise((resolve, reject) => { + const child = spawn("node", [HOOK], { + env: { ...process.env, CLAUDE_PLUGIN_ROOT: root }, + }); + let out = ""; + child.stdout.on("data", (d) => (out += d.toString())); + child.on("error", reject); + child.on("close", () => resolve(out)); + child.stdin.write(JSON.stringify({ tool_name: toolName })); + child.stdin.end(); + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +const glean = (tool: string) => `mcp__plugin_glean-vnext_glean__${tool}`; +const hitlOn = { ENABLE_HITL: "true" }; +const hitlOff = { ENABLE_HITL: "false" }; + +describe("auto-approve-run-tool hook (Claude Code PreToolUse)", () => { + it("allows glean run_tool when HITL is on", async () => { + const out = await runHook(glean("run_tool"), hitlOn); + expect(JSON.parse(out).hookSpecificOutput.permissionDecision).toBe("allow"); + }); + + it("never allows when HITL is off (safety)", async () => { + const out = await runHook(glean("run_tool"), hitlOff); + expect(out.trim()).toBe(""); + }); + + it("ignores a non-glean run_tool (scoped to this plugin)", async () => { + const out = await runHook("mcp__other-server__run_tool", hitlOn); + expect(out.trim()).toBe(""); + }); + + it("ignores glean tools other than run_tool (e.g. find_skills)", async () => { + const out = await runHook(glean("find_skills"), hitlOn); + expect(out.trim()).toBe(""); + }); +});