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();