Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/glean/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check what does it mean to support it in Codex and Cursor as well?

Cursor - https://cursor.com/docs/hooks#pretooluse
Codex - https://developers.openai.com/codex/hooks#pretooluse

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @eshwar-sundar-glean we don't have this problem in cursor/codex. We don't need to solve there.

"name": "glean-vnext",
"version": "0.2.33",
"version": "0.2.34",
"description": "Glean plugin for discovering skills and running tools.",
"author": {
"name": "Glean"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/glean/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
63 changes: 63 additions & 0 deletions plugins/glean/hooks/auto-approve-run-tool.mjs
Original file line number Diff line number Diff line change
@@ -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);
15 changes: 15 additions & 0 deletions plugins/glean/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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\""
}
]
}
]
}
}
64 changes: 64 additions & 0 deletions tests/auto-approve-hook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Promise<string> {
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<string>((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("");
});
});
Loading