Lumi/plugins/lumi_ai/backend/tool_router.js
2026-06-14 04:18:36 +02:00

447 lines
15 KiB
JavaScript

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
};