From 393e5a4e9e3dcf0af78a6c011cfdadd86bd481fd Mon Sep 17 00:00:00 2001 From: Glean Code Writer Date: Sun, 21 Jun 2026 06:38:25 +0000 Subject: [PATCH 1/3] Add "Always allow" option to HITL approval prompts When HITL is enabled, tool approval prompts now include an "Always allow" checkbox. Checking it before accepting persists the preference so future calls to the same tool skip the approval gate entirely. - New tool-permissions-store persists auto-approved tool names to disk - Elicitation schema includes an always_allow boolean field - Auto-approved tools bypass elicitation on subsequent calls - setup({reset}) clears saved permissions alongside other state --- plugins/glean/.claude-plugin/plugin.json | 2 +- plugins/glean/dist/index.js | 351 ++++++++++++++--------- src/index.ts | 2 + src/tool-permissions-store.ts | 62 ++++ src/tools/run-tool.ts | 27 +- tests/run-tool.test.ts | 65 +++++ 6 files changed, 363 insertions(+), 146 deletions(-) create mode 100644 src/tool-permissions-store.ts diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index 5b5ffa1..0800998 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.27", + "version": "0.2.28", "description": "Glean plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/dist/index.js b/plugins/glean/dist/index.js index fdd4b57..b14dfbf 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -3236,8 +3236,8 @@ var require_utils = __commonJS({ } return ind; } - function removeDotSegments(path8) { - let input = path8; + function removeDotSegments(path9) { + let input = path9; const output = []; let nextSlash = -1; let len = 0; @@ -3489,8 +3489,8 @@ var require_schemes = __commonJS({ wsComponent.secure = void 0; } if (wsComponent.resourceName) { - const [path8, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path8 && path8 !== "/" ? path8 : void 0; + const [path9, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path9 && path9 !== "/" ? path9 : void 0; wsComponent.query = query; wsComponent.resourceName = void 0; } @@ -6883,12 +6883,12 @@ var require_dist = __commonJS({ throw new Error(`Unknown format "${name}"`); return f; }; - function addFormats(ajv, list, fs8, exportName) { + function addFormats(ajv, list, fs9, exportName) { var _a3; var _b; (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; for (const f of list) - ajv.addFormat(f, fs8[f]); + ajv.addFormat(f, fs9[f]); } module.exports = exports = formatsPlugin; Object.defineProperty(exports, "__esModule", { value: true }); @@ -6973,17 +6973,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path8) { - const ctrl = callVisitor(key, node, visitor, path8); + function visit_(key, node, visitor, path9) { + const ctrl = callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path8, ctrl); - return visit_(key, ctrl, visitor, path8); + replaceNode(key, path9, ctrl); + return visit_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path8 = Object.freeze(path8.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path8); + const ci = visit_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -6994,13 +6994,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path8 = Object.freeze(path8.concat(node)); - const ck = visit_("key", node.key, visitor, path8); + path9 = Object.freeze(path9.concat(node)); + const ck = visit_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path8); + const cv = visit_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -7021,17 +7021,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path8) { - const ctrl = await callVisitor(key, node, visitor, path8); + async function visitAsync_(key, node, visitor, path9) { + const ctrl = await callVisitor(key, node, visitor, path9); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path8, ctrl); - return visitAsync_(key, ctrl, visitor, path8); + replaceNode(key, path9, ctrl); + return visitAsync_(key, ctrl, visitor, path9); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path8 = Object.freeze(path8.concat(node)); + path9 = Object.freeze(path9.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path8); + const ci = await visitAsync_(i, node.items[i], visitor, path9); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -7042,13 +7042,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path8 = Object.freeze(path8.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path8); + path9 = Object.freeze(path9.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path9); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path8); + const cv = await visitAsync_("value", node.value, visitor, path9); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -7075,23 +7075,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path8) { + function callVisitor(key, node, visitor, path9) { if (typeof visitor === "function") - return visitor(key, node, path8); + return visitor(key, node, path9); if (identity.isMap(node)) - return visitor.Map?.(key, node, path8); + return visitor.Map?.(key, node, path9); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path8); + return visitor.Seq?.(key, node, path9); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path8); + return visitor.Pair?.(key, node, path9); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path8); + return visitor.Scalar?.(key, node, path9); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path8); + return visitor.Alias?.(key, node, path9); return void 0; } - function replaceNode(key, path8, node) { - const parent = path8[path8.length - 1]; + function replaceNode(key, path9, node) { + const parent = path9[path9.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -7701,10 +7701,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path8, value) { + function collectionFromPath(schema, path9, value) { let v = value; - for (let i = path8.length - 1; i >= 0; --i) { - const k = path8[i]; + for (let i = path9.length - 1; i >= 0; --i) { + const k = path9[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -7723,7 +7723,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path8) => path8 == null || typeof path8 === "object" && !!path8[Symbol.iterator]().next().done; + var isEmptyPath = (path9) => path9 == null || typeof path9 === "object" && !!path9[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -7753,11 +7753,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path8, value) { - if (isEmptyPath(path8)) + addIn(path9, value) { + if (isEmptyPath(path9)) this.add(value); else { - const [key, ...rest] = path8; + const [key, ...rest] = path9; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -7771,8 +7771,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path8) { - const [key, ...rest] = path8; + deleteIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -7786,8 +7786,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path8, keepScalar) { - const [key, ...rest] = path8; + getIn(path9, keepScalar) { + const [key, ...rest] = path9; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -7805,8 +7805,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path8) { - const [key, ...rest] = path8; + hasIn(path9) { + const [key, ...rest] = path9; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -7816,8 +7816,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path8, value) { - const [key, ...rest] = path8; + setIn(path9, value) { + const [key, ...rest] = path9; if (rest.length === 0) { this.set(key, value); } else { @@ -10332,9 +10332,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path8, value) { + addIn(path9, value) { if (assertCollection(this.contents)) - this.contents.addIn(path8, value); + this.contents.addIn(path9, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -10409,14 +10409,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path8) { - if (Collection.isEmptyPath(path8)) { + deleteIn(path9) { + if (Collection.isEmptyPath(path9)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path8) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path9) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -10431,10 +10431,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path8, keepScalar) { - if (Collection.isEmptyPath(path8)) + getIn(path9, keepScalar) { + if (Collection.isEmptyPath(path9)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path8, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path9, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -10445,10 +10445,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path8) { - if (Collection.isEmptyPath(path8)) + hasIn(path9) { + if (Collection.isEmptyPath(path9)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path8) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path9) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -10465,13 +10465,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path8, value) { - if (Collection.isEmptyPath(path8)) { + setIn(path9, value) { + if (Collection.isEmptyPath(path9)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path8), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path9), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path8, value); + this.contents.setIn(path9, value); } } /** @@ -12431,9 +12431,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path8) => { + visit.itemAtPath = (cst, path9) => { let item = cst; - for (const [field, index] of path8) { + for (const [field, index] of path9) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -12442,23 +12442,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path8) => { - const parent = visit.itemAtPath(cst, path8.slice(0, -1)); - const field = path8[path8.length - 1][0]; + visit.parentCollection = (cst, path9) => { + const parent = visit.itemAtPath(cst, path9.slice(0, -1)); + const field = path9[path9.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path8, item, visitor) { - let ctrl = visitor(item, path8); + function _visit(path9, item, visitor) { + let ctrl = visitor(item, path9); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path8.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path9.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -12469,10 +12469,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path8); + ctrl = ctrl(item, path9); } } - return typeof ctrl === "function" ? ctrl(item, path8) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path9) : ctrl; } exports.visit = visit; } @@ -13774,14 +13774,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs8 = this.flowScalar(this.type); + const fs9 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs8, sep: [] }); + map.items.push({ start, key: fs9, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs8); + this.stack.push(fs9); } else { - Object.assign(it, { key: fs8, sep: [] }); + Object.assign(it, { key: fs9, sep: [] }); this.onKeyLine = true; } return; @@ -13909,13 +13909,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs8 = this.flowScalar(this.type); + const fs9 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs8, sep: [] }); + fc.items.push({ start: [], key: fs9, sep: [] }); else if (it.sep) - this.stack.push(fs8); + this.stack.push(fs9); else - Object.assign(it, { key: fs8, sep: [] }); + Object.assign(it, { key: fs9, sep: [] }); return; } case "flow-map-end": @@ -14466,10 +14466,10 @@ function mergeDefs(...defs) { function cloneDef(schema) { return mergeDefs(schema._zod.def); } -function getElementAtPath(obj, path8) { - if (!path8) +function getElementAtPath(obj, path9) { + if (!path9) return obj; - return path8.reduce((acc, key) => acc?.[key], obj); + return path9.reduce((acc, key) => acc?.[key], obj); } function promiseAllObject(promisesObj) { const keys = Object.keys(promisesObj); @@ -14878,11 +14878,11 @@ function explicitlyAborted(x, startIndex = 0) { } return false; } -function prefixIssues(path8, issues) { +function prefixIssues(path9, issues) { return issues.map((iss) => { var _a3; (_a3 = iss).path ?? (_a3.path = []); - iss.path.unshift(path8); + iss.path.unshift(path9); return iss; }); } @@ -15029,16 +15029,16 @@ function flattenError(error2, mapper = (issue2) => issue2.message) { } function formatError(error2, mapper = (issue2) => issue2.message) { const fieldErrors = { _errors: [] }; - const processError = (error3, path8 = []) => { + const processError = (error3, path9 = []) => { for (const issue2 of error3.issues) { if (issue2.code === "invalid_union" && issue2.errors.length) { - issue2.errors.map((issues) => processError({ issues }, [...path8, ...issue2.path])); + issue2.errors.map((issues) => processError({ issues }, [...path9, ...issue2.path])); } else if (issue2.code === "invalid_key") { - processError({ issues: issue2.issues }, [...path8, ...issue2.path]); + processError({ issues: issue2.issues }, [...path9, ...issue2.path]); } else if (issue2.code === "invalid_element") { - processError({ issues: issue2.issues }, [...path8, ...issue2.path]); + processError({ issues: issue2.issues }, [...path9, ...issue2.path]); } else { - const fullpath = [...path8, ...issue2.path]; + const fullpath = [...path9, ...issue2.path]; if (fullpath.length === 0) { fieldErrors._errors.push(mapper(issue2)); } else { @@ -22982,9 +22982,9 @@ var StdioServerTransport = class { }; // src/index.ts -import path7 from "node:path"; -import fs7 from "node:fs"; -import { homedir as homedir4 } from "node:os"; +import path8 from "node:path"; +import fs8 from "node:fs"; +import { homedir as homedir5 } from "node:os"; // node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/client.js var ExperimentalClientTasks = class { @@ -25661,8 +25661,8 @@ async function handleFindSkills(remoteClient, skillsBaseDir, args) { } // src/tools/run-tool.ts -import fs4 from "node:fs/promises"; -import path4 from "node:path"; +import fs5 from "node:fs/promises"; +import path5 from "node:path"; // src/tools/approval-args.ts import fs3 from "node:fs/promises"; @@ -25764,6 +25764,57 @@ async function writeApprovalArgsFile(toolName, args) { return file; } +// src/tool-permissions-store.ts +import fs4 from "node:fs"; +import path4 from "node:path"; +import { homedir as homedir2 } from "node:os"; +var PERMISSIONS_FILENAME = "mcp-tool-permissions.json"; +var DIR_MODE2 = 448; +var FILE_MODE2 = 384; +function resolvePermissionsDir() { + return process.env.PLUGIN_DATA_DIR || path4.join(homedir2(), ".glean"); +} +function permissionsFile() { + return path4.join(resolvePermissionsDir(), PERMISSIONS_FILENAME); +} +function loadPermissions() { + try { + const raw = fs4.readFileSync(permissionsFile(), "utf-8"); + const data = JSON.parse(raw); + if (!Array.isArray(data.autoApproved)) return { autoApproved: [] }; + return data; + } catch { + return { autoApproved: [] }; + } +} +function savePermissions(data) { + const filePath = permissionsFile(); + const dir = path4.dirname(filePath); + fs4.mkdirSync(dir, { recursive: true, mode: DIR_MODE2 }); + fs4.chmodSync(dir, DIR_MODE2); + fs4.writeFileSync(filePath, JSON.stringify(data, null, 2), { + encoding: "utf-8", + mode: FILE_MODE2 + }); + fs4.chmodSync(filePath, FILE_MODE2); +} +function isToolAutoApproved(toolName) { + return loadPermissions().autoApproved.includes(toolName); +} +function setToolAutoApproved(toolName) { + const perms = loadPermissions(); + if (!perms.autoApproved.includes(toolName)) { + perms.autoApproved.push(toolName); + savePermissions(perms); + } +} +function clearToolPermissions() { + try { + fs4.rmSync(permissionsFile(), { force: true }); + } catch { + } +} + // src/tools/run-tool.ts var DEFAULT_FILE_ARG_MAX_BYTES = 1 * 1024 * 1024; var defaultHitlTimeoutMs = 3e5; @@ -25802,7 +25853,7 @@ async function resolveFileArgs(fileArgs, baseArgs) { `file_args.${argName} must be a non-empty string path` ); } - if (!path4.isAbsolute(filePathRaw)) { + if (!path5.isAbsolute(filePathRaw)) { throw new FileArgsError( `file_args.${argName} must be an absolute path; got "${filePathRaw}"` ); @@ -25814,7 +25865,7 @@ async function resolveFileArgs(fileArgs, baseArgs) { } let stat; try { - stat = await fs4.stat(filePathRaw); + stat = await fs5.stat(filePathRaw); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new FileArgsError( @@ -25831,18 +25882,18 @@ async function resolveFileArgs(fileArgs, baseArgs) { `file_args.${argName}: "${filePathRaw}" is ${stat.size} bytes, exceeds ${maxBytes} byte limit (set GLEAN_FILE_ARG_MAX_BYTES to override)` ); } - merged[argName] = await fs4.readFile(filePathRaw, "utf-8"); + merged[argName] = await fs5.readFile(filePathRaw, "utf-8"); } return merged; } async function findToolJson(skillsBaseDir, toolName) { try { - const skillDirs = await fs4.readdir(skillsBaseDir, { withFileTypes: true }); + const skillDirs = await fs5.readdir(skillsBaseDir, { withFileTypes: true }); for (const dir of skillDirs) { if (!dir.isDirectory()) continue; - const toolPath = path4.join(skillsBaseDir, dir.name, "tools", `${toolName}.json`); + const toolPath = path5.join(skillsBaseDir, dir.name, "tools", `${toolName}.json`); try { - const content = await fs4.readFile(toolPath, "utf-8"); + const content = await fs5.readFile(toolPath, "utf-8"); return JSON.parse(content); } catch { continue; @@ -25889,7 +25940,7 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { const hitlEnabled = process.env.ENABLE_HITL === "true"; if (hitlEnabled && mcpServer.getClientCapabilities()?.elicitation) { const toolMeta = await findToolJson(skillsBaseDir, toolName); - if (toolMeta?.requires_approval) { + if (toolMeta?.requires_approval && !isToolAutoApproved(toolName)) { const message = await buildApprovalMessage( mcpServer, toolName, @@ -25900,7 +25951,17 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { const result = await mcpServer.elicitInput( { message, - requestedSchema: { type: "object", properties: {} } + requestedSchema: { + type: "object", + properties: { + always_allow: { + type: "boolean", + title: "Always allow", + description: "Skip approval for future calls to this tool", + default: false + } + } + } }, { timeout } ); @@ -25914,6 +25975,9 @@ async function handleRunTool(remoteClient, mcpServer, skillsBaseDir, args) { ] }; } + if (result.content && typeof result.content === "object" && result.content.always_allow === true) { + setToolAutoApproved(toolName); + } } catch (err) { const detail = err instanceof Error ? err.message : String(err); return { @@ -25959,21 +26023,21 @@ function runToolAnnotations(enableHitl, clientSupportsElicitation) { } // src/url-config-store.ts -import fs5 from "node:fs"; -import path5 from "node:path"; -import { homedir as homedir2 } from "node:os"; +import fs6 from "node:fs"; +import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; var CONFIG_FILENAME = "mcp-server-url.json"; -var DIR_MODE2 = 448; -var FILE_MODE2 = 384; +var DIR_MODE3 = 448; +var FILE_MODE3 = 384; function resolveConfigDir() { - return process.env.PLUGIN_DATA_DIR || path5.join(homedir2(), ".glean"); + return process.env.PLUGIN_DATA_DIR || path6.join(homedir3(), ".glean"); } function configFile() { - return path5.join(resolveConfigDir(), CONFIG_FILENAME); + return path6.join(resolveConfigDir(), CONFIG_FILENAME); } function loadServerUrl() { try { - const raw = fs5.readFileSync(configFile(), "utf-8"); + const raw = fs6.readFileSync(configFile(), "utf-8"); const data = JSON.parse(raw); if (typeof data.serverUrl !== "string" || !data.serverUrl) return void 0; return data.serverUrl; @@ -25983,39 +26047,39 @@ function loadServerUrl() { } function saveServerUrl(url2) { const filePath = configFile(); - const dir = path5.dirname(filePath); - fs5.mkdirSync(dir, { recursive: true, mode: DIR_MODE2 }); - fs5.chmodSync(dir, DIR_MODE2); + const dir = path6.dirname(filePath); + fs6.mkdirSync(dir, { recursive: true, mode: DIR_MODE3 }); + fs6.chmodSync(dir, DIR_MODE3); const data = { serverUrl: url2 }; - fs5.writeFileSync(filePath, JSON.stringify(data, null, 2), { + fs6.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: "utf-8", - mode: FILE_MODE2 + mode: FILE_MODE3 }); - fs5.chmodSync(filePath, FILE_MODE2); + fs6.chmodSync(filePath, FILE_MODE3); } function clearServerUrl() { try { - fs5.rmSync(configFile(), { force: true }); + fs6.rmSync(configFile(), { force: true }); } catch { } } // src/remote-tools-cache-store.ts -import fs6 from "node:fs"; -import path6 from "node:path"; -import { homedir as homedir3 } from "node:os"; +import fs7 from "node:fs"; +import path7 from "node:path"; +import { homedir as homedir4 } from "node:os"; var CACHE_FILENAME = "remote-tools-cache.json"; -var DIR_MODE3 = 448; -var FILE_MODE3 = 384; +var DIR_MODE4 = 448; +var FILE_MODE4 = 384; function resolveCacheDir() { - return process.env.PLUGIN_DATA_DIR || path6.join(homedir3(), ".glean"); + return process.env.PLUGIN_DATA_DIR || path7.join(homedir4(), ".glean"); } function cacheFile() { - return path6.join(resolveCacheDir(), CACHE_FILENAME); + return path7.join(resolveCacheDir(), CACHE_FILENAME); } function readStore() { try { - const raw = fs6.readFileSync(cacheFile(), "utf-8"); + const raw = fs7.readFileSync(cacheFile(), "utf-8"); const data = JSON.parse(raw); if (data && typeof data === "object" && !Array.isArray(data)) { return data; @@ -26027,14 +26091,14 @@ function readStore() { } function writeStore(store) { const filePath = cacheFile(); - const dir = path6.dirname(filePath); - fs6.mkdirSync(dir, { recursive: true, mode: DIR_MODE3 }); - fs6.chmodSync(dir, DIR_MODE3); - fs6.writeFileSync(filePath, JSON.stringify(store, null, 2), { + const dir = path7.dirname(filePath); + fs7.mkdirSync(dir, { recursive: true, mode: DIR_MODE4 }); + fs7.chmodSync(dir, DIR_MODE4); + fs7.writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", - mode: FILE_MODE3 + mode: FILE_MODE4 }); - fs6.chmodSync(filePath, FILE_MODE3); + fs7.chmodSync(filePath, FILE_MODE4); } function loadRemoteTools(serverUrl) { if (!serverUrl) return []; @@ -26057,14 +26121,14 @@ function saveRemoteTools(serverUrl, tools) { function clearRemoteTools(serverUrl) { try { if (!serverUrl) { - fs6.rmSync(cacheFile(), { force: true }); + fs7.rmSync(cacheFile(), { force: true }); return; } const store = readStore(); if (store[serverUrl] !== void 0) { delete store[serverUrl]; if (Object.keys(store).length === 0) { - fs6.rmSync(cacheFile(), { force: true }); + fs7.rmSync(cacheFile(), { force: true }); } else { writeStore(store); } @@ -26196,14 +26260,14 @@ Then call this tool again with the server_url parameter set to the URL you copie var SETUP_NEEDED_ERROR = "Glean is not configured yet. Call the `setup` tool first to provide your Glean Server URL before using find_skills or run_tool."; var AUTH_REDIRECT_TO_SETUP_TEXT = "[SETUP_REQUIRED]\n\nAuthentication is required. Call the `setup` tool (no arguments) to sign in to Glean, then retry this tool."; function resolveLogPath() { - const base = process.env.PLUGIN_DATA_DIR || path7.join(homedir4(), ".glean"); - return path7.join(base, "glean-server.log"); + const base = process.env.PLUGIN_DATA_DIR || path8.join(homedir5(), ".glean"); + return path8.join(base, "glean-server.log"); } var LOG_PATH = resolveLogPath(); try { - const logDir = path7.dirname(LOG_PATH); - fs7.mkdirSync(logDir, { recursive: true, mode: 448 }); - fs7.chmodSync(logDir, 448); + const logDir = path8.dirname(LOG_PATH); + fs8.mkdirSync(logDir, { recursive: true, mode: 448 }); + fs8.chmodSync(logDir, 448); } catch { } function logLine(label, detail) { @@ -26212,8 +26276,8 @@ function logLine(label, detail) { const line = `${ts} ${label}${suffix} `; try { - fs7.appendFileSync(LOG_PATH, line, { mode: 384 }); - fs7.chmodSync(LOG_PATH, 384); + fs8.appendFileSync(LOG_PATH, line, { mode: 384 }); + fs8.chmodSync(LOG_PATH, 384); } catch { } console.error(line.trimEnd()); @@ -26222,7 +26286,7 @@ function resolveSkillsBaseDir() { if (process.env.SKILLS_BASE_DIR) { return process.env.SKILLS_BASE_DIR; } - return path7.join("/tmp", "glean-skills-cache"); + return path8.join("/tmp", "glean-skills-cache"); } var server = new Server( { name: "glean", version: "1.0.0" }, @@ -26654,6 +26718,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { clearServerUrl(); clearCredentials(); clearRemoteTools(); + clearToolPermissions(); oauthProvider = void 0; cachedRemoteTools = []; logLine("setup.reset"); diff --git a/src/index.ts b/src/index.ts index b32255b..4be79b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { saveRemoteTools, clearRemoteTools, } from "./remote-tools-cache-store.js"; +import { clearToolPermissions } from "./tool-permissions-store.js"; import { REMOTE_TOOLS_ALLOWLIST, dispatchRemoteTool, @@ -680,6 +681,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { clearServerUrl(); clearCredentials(); clearRemoteTools(); + clearToolPermissions(); oauthProvider = undefined; cachedRemoteTools = []; logLine("setup.reset"); diff --git a/src/tool-permissions-store.ts b/src/tool-permissions-store.ts new file mode 100644 index 0000000..fd03b4b --- /dev/null +++ b/src/tool-permissions-store.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +const PERMISSIONS_FILENAME = "mcp-tool-permissions.json"; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; + +function resolvePermissionsDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); +} + +function permissionsFile(): string { + return path.join(resolvePermissionsDir(), PERMISSIONS_FILENAME); +} + +interface StoredPermissions { + autoApproved: string[]; +} + +function loadPermissions(): StoredPermissions { + try { + const raw = fs.readFileSync(permissionsFile(), "utf-8"); + const data = JSON.parse(raw) as StoredPermissions; + if (!Array.isArray(data.autoApproved)) return { autoApproved: [] }; + return data; + } catch { + return { autoApproved: [] }; + } +} + +function savePermissions(data: StoredPermissions): void { + const filePath = permissionsFile(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE }); + fs.chmodSync(dir, DIR_MODE); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { + encoding: "utf-8", + mode: FILE_MODE, + }); + fs.chmodSync(filePath, FILE_MODE); +} + +export function isToolAutoApproved(toolName: string): boolean { + return loadPermissions().autoApproved.includes(toolName); +} + +export function setToolAutoApproved(toolName: string): void { + const perms = loadPermissions(); + if (!perms.autoApproved.includes(toolName)) { + perms.autoApproved.push(toolName); + savePermissions(perms); + } +} + +export function clearToolPermissions(): void { + try { + fs.rmSync(permissionsFile(), { force: true }); + } catch { + /* ignore */ + } +} diff --git a/src/tools/run-tool.ts b/src/tools/run-tool.ts index b4fa926..9d00fb2 100644 --- a/src/tools/run-tool.ts +++ b/src/tools/run-tool.ts @@ -5,6 +5,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { callRemoteTool } from "../remote-client.js"; import { buildCompactArgs, writeApprovalArgsFile } from "./approval-args.js"; +import { + isToolAutoApproved, + setToolAutoApproved, +} from "../tool-permissions-store.js"; const DEFAULT_FILE_ARG_MAX_BYTES = 1 * 1024 * 1024; @@ -199,7 +203,7 @@ export async function handleRunTool( if (hitlEnabled && mcpServer.getClientCapabilities()?.elicitation) { const toolMeta = await findToolJson(skillsBaseDir, toolName); - if (toolMeta?.requires_approval) { + if (toolMeta?.requires_approval && !isToolAutoApproved(toolName)) { const message = await buildApprovalMessage( mcpServer, toolName, @@ -211,7 +215,18 @@ export async function handleRunTool( const result = await mcpServer.elicitInput( { message, - requestedSchema: { type: "object", properties: {} } as any, + requestedSchema: { + type: "object", + properties: { + always_allow: { + type: "boolean", + title: "Always allow", + description: + "Skip approval for future calls to this tool", + default: false, + }, + }, + } as any, }, { timeout }, ); @@ -226,6 +241,14 @@ export async function handleRunTool( ], }; } + + if ( + result.content && + typeof result.content === "object" && + (result.content as Record).always_allow === true + ) { + setToolAutoApproved(toolName); + } } 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/tests/run-tool.test.ts b/tests/run-tool.test.ts index 0fa8d64..33fed17 100644 --- a/tests/run-tool.test.ts +++ b/tests/run-tool.test.ts @@ -13,6 +13,11 @@ import { buildCompactArgs, formatArgumentsForFile, } from "../src/tools/approval-args.js"; +import { + isToolAutoApproved, + setToolAutoApproved, + clearToolPermissions, +} from "../src/tool-permissions-store.js"; describe("resolveFileArgs", () => { let tmpDir: string; @@ -345,6 +350,66 @@ describe("handleRunTool (HITL)", () => { expect(remote.callTool).toHaveBeenCalledTimes(1); }); + it("includes always_allow in the elicitation schema", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + const schema = elicit.mock.calls[0][0].requestedSchema; + expect(schema.properties.always_allow).toBeDefined(); + expect(schema.properties.always_allow.type).toBe("boolean"); + }); + + it("saves auto-approve when user accepts with always_allow=true", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi + .fn() + .mockResolvedValue({ action: "accept", content: { always_allow: true } }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + clearToolPermissions(); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(isToolAutoApproved("jirasearch")).toBe(true); + expect(remote.callTool).toHaveBeenCalledTimes(1); + clearToolPermissions(); + }); + + it("does not save auto-approve when user accepts without always_allow", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + clearToolPermissions(); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(isToolAutoApproved("jirasearch")).toBe(false); + expect(remote.callTool).toHaveBeenCalledTimes(1); + clearToolPermissions(); + }); + + it("skips elicitation for auto-approved tools", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const server = makeServer({ elicitation: true }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + setToolAutoApproved("jirasearch"); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + clearToolPermissions(); + }); + it("spills large arguments to a file and keeps the prompt short", async () => { vi.stubEnv("ENABLE_HITL", "true"); const remote = makeRemote(); From 06143ac4b97d646ebd3da87b4126eb43c1f29295 Mon Sep 17 00:00:00 2001 From: Glean Code Writer Date: Sun, 21 Jun 2026 06:39:18 +0000 Subject: [PATCH 2/3] Update package-lock.json after npm install --- package-lock.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8951082..240612a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -899,7 +899,6 @@ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1463,7 +1462,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1701,7 +1699,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -2012,7 +2009,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2459,7 +2455,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3241,7 +3236,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -3257,7 +3251,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From c53f705f315111a38e96a843ead101bf385cefcf Mon Sep 17 00:00:00 2001 From: Glean Code Writer Date: Sun, 21 Jun 2026 06:48:32 +0000 Subject: [PATCH 3/3] Revert unrelated package-lock.json changes The peer flag removals were an unintended side effect of running npm install with a different npm version and are unrelated to the always-allow feature. --- package-lock.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/package-lock.json b/package-lock.json index 240612a..8951082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -899,6 +899,7 @@ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1462,6 +1463,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1699,6 +1701,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2009,6 +2012,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2455,6 +2459,7 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3236,6 +3241,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -3251,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }