const crypto = require("crypto"); const { roleAllows } = require("./permissions"); class ToolRegistry { constructor(audit) { this.tools = new Map(); this.confirmations = new Map(); this.audit = typeof audit === "function" ? audit : () => {}; } register(definition) { validateDefinition(definition); if (this.tools.has(definition.tool_id)) { throw new Error(`AI tool ${definition.tool_id} is already registered.`); } const normalized = { required_role: "user", confirmation_required: true, risk_level: "sensitive", read_only: false, use_cases: [], output_expectations: "", ...definition }; this.tools.set(normalized.tool_id, normalized); return () => this.unregister(normalized.tool_id, normalized.owning_plugin); } unregister(toolId, owner = null) { const definition = this.tools.get(toolId); if (!definition || (owner && definition.owning_plugin !== owner)) return false; this.tools.delete(toolId); for (const [id, pending] of this.confirmations) { if (pending.def?.tool_id === toolId) this.confirmations.delete(id); } return true; } unregisterOwner(owner) { let removed = 0; for (const [id, definition] of this.tools) { if (definition.owning_plugin === owner && this.unregister(id, owner)) removed += 1; } return removed; } has(toolId) { return this.tools.has(toolId); } list(role) { return [...this.tools.values()] .filter((tool) => roleAllows(role, tool.required_role)) .map(publicDefinition); } inspect({ role, user, context = null } = {}) { const decisions = [...this.tools.values()].map((definition) => exposureDecision(definition, { role, user, context }) ); return { considered: decisions, exposed: decisions.filter((decision) => decision.exposed).map((decision) => decision.tool) }; } validate(toolId, args, role) { const definition = this.tools.get(toolId); if (!definition) throw toolError("not_registered", "Tool is not registered."); if (!roleAllows(role, definition.required_role)) { throw toolError("permission_blocked", "Permission denied for this tool."); } const schema = definition.schema || {}; const input = args && typeof args === "object" && !Array.isArray(args) ? args : {}; const unknown = Object.keys(input).filter((key) => !Object.hasOwn(schema, key)); if (unknown.length) throw toolError("schema_invalid", `Unknown tool argument: ${unknown[0]}.`); const clean = {}; for (const [key, spec] of Object.entries(schema)) { const descriptor = typeof spec === "string" ? { type: spec, required: true } : spec || {}; const value = input[key]; if (value == null) { if (descriptor.required === false) continue; throw toolError("schema_invalid", `${key} is required.`); } clean[key] = normalizeArgument(key, value, descriptor); } return { def: definition, args: clean }; } prepare({ tool, args, user, role, sessionId, context = null }) { const definition = this.tools.get(tool); if (!definition) { this.recordDecision({ user, role, context, tool, args, decision: "rejected", rejected_reason: "not_registered" }); throw toolError("not_registered", "Tool is not registered."); } const exposure = exposureDecision(definition, { role, user, context }); if (!exposure.exposed) { this.recordDecision({ user, role, context, tool, args, decision: "rejected", rejected_reason: exposure.reason }); throw toolError(exposure.reason, exposure.message); } let checked; try { checked = this.validate(tool, args, role); } catch (error) { this.recordDecision({ user, role, context, tool, args, decision: "rejected", rejected_reason: error.code || "schema_invalid" }); throw error; } let allowed; try { allowed = checked.def.permission_check({ user, arguments: checked.args, required_permission: checked.def.required_permission, context, phase: "execute" }); } catch { allowed = false; } if (allowed && typeof allowed.then === "function") { this.recordDecision({ user, role, context, tool, args: checked.args, decision: "rejected", rejected_reason: "permission_blocked" }); throw toolError("permission_blocked", "AI tool permission checks must be synchronous."); } if (!allowed) { this.recordDecision({ user, role, context, tool, args: checked.args, decision: "rejected", rejected_reason: "permission_blocked" }); throw toolError("permission_blocked", "The requesting user does not have permission for this action."); } this.recordDecision({ user, role, context, tool, args: checked.args, decision: "selected", rejected_reason: null }); if (!checked.def.confirmation_required) return { execute: true, checked, context }; const id = crypto.randomUUID(); this.confirmations.set(id, { id, userId: user.id, sessionId, expiresAt: Date.now() + 120000, context, ...checked }); return { execute: false, checked, context, confirmation: { id, display_name: checked.def.display_name, arguments: checked.args, expires_at: Date.now() + 120000 } }; } async execute({ checked, user, requestId, context = null }) { const started = Date.now(); try { const result = await checked.def.workflow_handler({ arguments: checked.args, user, ctx: context, initiated_via_ai: true, ai_request_id: requestId }); this.audit({ kind: "tool", status: "success", user_id: user.id, actor: auditActor(user), origin: contextOrigin(context), tool_requested: checked.def.tool_id, selected_tool: checked.def.tool_id, tool_executed: true, arguments_summary: summarizeArguments(checked.args), execution_ms: Date.now() - started }); return result; } catch (error) { this.audit({ kind: "tool", status: "failed", user_id: user.id, actor: auditActor(user), origin: contextOrigin(context), tool_requested: checked.def.tool_id, selected_tool: checked.def.tool_id, tool_executed: false, arguments_summary: summarizeArguments(checked.args), execution_ms: Date.now() - started, rejected_reason: error.code || "execution_failed" }); throw error; } } async confirm({ id, user, sessionId }) { const pending = this.confirmations.get(id); this.confirmations.delete(id); if (!pending || pending.expiresAt < Date.now() || pending.userId !== user.id || pending.sessionId !== sessionId) { throw new Error("Confirmation is invalid or expired."); } return this.execute({ checked: { def: pending.def, args: pending.args }, user, requestId: id, context: pending.context }); } cancel(id, userId) { const pending = this.confirmations.get(id); if (pending?.userId !== userId) return false; this.confirmations.delete(id); return true; } recordDecision({ user, role, context, tool, args, decision, rejected_reason }) { this.audit({ kind: "tool_decision", status: decision === "rejected" ? "rejected" : "selected", user_id: user?.id || null, actor: auditActor(user, role), origin: contextOrigin(context), selected_tool: decision === "selected" ? tool : null, tool_requested: tool || null, arguments_summary: summarizeArguments(args), rejected_reason: rejected_reason || null }); } } function exposureDecision(definition, { role = "user", user = {}, context = null } = {}) { const tool = publicDefinition(definition); if (!roleAllows(role, definition.required_role)) { return hidden(tool, "permission_blocked", "Permission denied: the selected role is below the tool's required role."); } if (!definition.read_only && context?.permission_context?.webui_actions_allowed === false) { return hidden(tool, "scope_blocked", "This action tool requires a WebUI action-capable context."); } const origin = contextOrigin(context); if (Array.isArray(definition.allowed_origins) && !definition.allowed_origins.includes(origin)) { return hidden(tool, "origin_blocked", `The tool does not allow the ${origin} origin.`); } if (typeof definition.origin_check === "function") { try { const result = definition.origin_check({ user, context, origin, phase: "prompt" }); if (result && typeof result.then === "function") { return hidden(tool, "origin_blocked", "Tool origin checks must be synchronous."); } if (result !== true) return hidden(tool, "origin_blocked", `The tool is disabled for the ${origin} origin.`); } catch { return hidden(tool, "origin_blocked", "The tool origin check failed."); } } if (typeof definition.scope_check === "function") { try { const result = definition.scope_check({ user, context, origin, phase: "prompt" }); if (result && typeof result.then === "function") { return hidden(tool, "scope_blocked", "Tool scope checks must be synchronous."); } if (result !== true) return hidden(tool, "scope_blocked", "The tool is outside this request context."); } catch { return hidden(tool, "scope_blocked", "The tool scope check failed."); } } const promptCheck = definition.prompt_permission_check || definition.permission_check; try { const allowed = promptCheck({ user, arguments: {}, required_permission: definition.required_permission, context, phase: "prompt" }); if (allowed && typeof allowed.then === "function") { return hidden(tool, "permission_blocked", "Tool prompt permission checks must be synchronous."); } if (allowed !== true) return hidden(tool, "permission_blocked", "The backend permission check denied prompt exposure."); } catch { return hidden(tool, "permission_blocked", "The backend permission check denied prompt exposure."); } return { tool, exposed: true, reason: "exposed", message: "Registered and permitted for this context." }; } function parseToolCall(text) { const parsed = parseToolCallResult(text); return parsed.status === "valid" ? parsed.call : null; } function parseToolCallResult(text) { const value = String(text || "").trim(); if (!value) return { status: "none", call: null, error: null }; let parsed; try { parsed = JSON.parse(value); } catch { return /"type"\s*:\s*"tool_call"/i.test(value) ? { status: "malformed", call: null, error: "Tool-call JSON was malformed or surrounded by extra text." } : { status: "none", call: null, error: null }; } if (parsed?.type !== "tool_call") return { status: "none", call: null, error: null }; const keys = Object.keys(parsed); if (keys.length !== 3 || !["type", "tool", "arguments"].every((key) => keys.includes(key))) { return { status: "malformed", call: null, error: "Tool-call JSON must contain only type, tool, and arguments." }; } if (typeof parsed.tool !== "string" || !parsed.tool || !parsed.arguments || typeof parsed.arguments !== "object" || Array.isArray(parsed.arguments)) { return { status: "malformed", call: null, error: "Tool-call JSON is missing a valid tool or arguments object." }; } return { status: "valid", call: { type: "tool_call", tool: parsed.tool, arguments: parsed.arguments }, error: null }; } function publicDefinition(definition) { const { workflow_handler, permission_check, prompt_permission_check, origin_check, scope_check, ...tool } = definition; return tool; } function validateDefinition(definition) { if (!definition?.tool_id || !definition.display_name || !definition.description || !definition.owning_plugin || !definition.required_permission || !definition.audit_category || typeof definition.workflow_handler !== "function" || typeof definition.permission_check !== "function" || !definition.schema || typeof definition.schema !== "object" || Array.isArray(definition.schema)) { throw new Error("Invalid AI tool definition."); } } function normalizeArgument(key, value, descriptor) { if (descriptor.type === "integer") { if (!Number.isInteger(Number(value))) throw toolError("schema_invalid", `${key} must be an integer.`); value = Number(value); } else if (descriptor.type === "number") { if (!Number.isFinite(Number(value))) throw toolError("schema_invalid", `${key} must be a number.`); value = Number(value); } else if (descriptor.type === "string") { if (typeof value !== "string") throw toolError("schema_invalid", `${key} must be a string.`); } else if (descriptor.type === "boolean") { if (typeof value !== "boolean") throw toolError("schema_invalid", `${key} must be a boolean.`); } else if (descriptor.type === "array") { if (!Array.isArray(value)) throw toolError("schema_invalid", `${key} must be an array.`); } else if (descriptor.type === "object") { if (!value || typeof value !== "object" || Array.isArray(value)) { throw toolError("schema_invalid", `${key} must be an object.`); } } if (Array.isArray(descriptor.enum) && !descriptor.enum.includes(value)) { throw toolError("schema_invalid", `${key} is invalid.`); } return value; } function summarizeArguments(args) { return Object.fromEntries(Object.entries(args || {}).map(([key, value]) => { if (/token|secret|password|key|authorization|cookie/i.test(key)) return [key, "[REDACTED]"]; if (typeof value === "string") return [key, { type: "string", length: value.length }]; if (Array.isArray(value)) return [key, { type: "array", length: value.length }]; return [key, { type: typeof value }]; })); } function contextOrigin(context) { const value = String(context?.origin || context?.platform || "other").toLowerCase(); return ["webui", "discord", "twitch", "youtube", "kick"].includes(value) ? value : "other"; } function auditActor(user, role = null) { return { user_id: user?.id || null, username: user?.username || null, role: role || (user?.isAdmin ? "admin" : user?.isMod ? "mod" : "user") }; } function hidden(tool, reason, message) { return { tool, exposed: false, reason, message }; } function toolError(code, message) { return Object.assign(new Error(message), { code }); } module.exports = { ToolRegistry, contextOrigin, exposureDecision, parseToolCall, parseToolCallResult, publicDefinition, summarizeArguments };