diff --git a/lmcp.lua b/lmcp.lua index df3875d..872e24a 100644 --- a/lmcp.lua +++ b/lmcp.lua @@ -48,6 +48,19 @@ function lmcp.new(name, opts) self.host = opts.host or "0.0.0.0" self.port = opts.port or 8080 self.tools = {} + -- Erlaubnisliste je Instanz (LMCP_TOOL_ALLOW, kommagetrennt). Ist sie + -- gesetzt, registriert `tool()` NUR diese Namen -- Built-ins wie Plugins. + -- Nicht gesetzt: alles wie bisher. Das ist die einzige Stelle, an der ein + -- Werkzeug entsteht, also die einzige, an der man es verhindern kann; + -- nachtraeglich loeschen muss jeden kuenftigen Eintrag kennen und veraltet. + self.tool_allow = nil + do + local roh = os.getenv("LMCP_TOOL_ALLOW") + if roh and roh:match("%S") then + self.tool_allow = {} + for n in roh:gmatch("[^,%s]+") do self.tool_allow[n] = true end + end + end -- Resources primitive (MCP 2025-06-18 §Server/Resources). Storage is -- always present; capability is advertised iff `opts.resources` is -- truthy OR at least one resource/template has been registered by @@ -182,6 +195,13 @@ function lmcp:tool(name, description, params_schema, handler, opts) end schema = clean end + -- Erlaubnisliste: stumm verweigern, damit ein Plugin, das ein nicht + -- erlaubtes Werkzeug anbietet, nicht abstuerzt -- es existiert einfach + -- nicht. `tools/list` und `tools/call` lesen beide dasselbe Register, + -- ein nicht registriertes Werkzeug ist also weder sichtbar noch rufbar. + if self.tool_allow and not self.tool_allow[name] then + return self + end self.tools[name] = { name = name, description = description, diff --git a/tests/test_tool_allow.lua b/tests/test_tool_allow.lua new file mode 100644 index 0000000..3041d47 --- /dev/null +++ b/tests/test_tool_allow.lua @@ -0,0 +1,124 @@ +-- Ausfuehrbare Zusicherung fuer LMCP_TOOL_ALLOW. +-- +-- Hintergrund: eine lmcp-Instanz konnte ihren Werkzeugsatz nur ERWEITERN. +-- tools.d-Dateien fuegen hinzu; der Grundstock aus server.lua bringt shell, +-- write_file und Verwandte mit, und eine Plugin-Datei kann nichts wegnehmen. +-- Am 2026-08-08 hatte damit jeder Agent mit dem Raum-Token eine Wurzelschale +-- im Raum-Container -- nachgewiesen: uid=0(root), Schreibzugriff auf +-- room.jsonl. Das ist keine Einbruchsluecke (eine Sicherheitsdomaene), aber +-- es macht jede Aussage ueber Rollentrennung unbelegbar. +-- +-- Geprueft wird an der REGISTRIERUNG, nicht nachtraeglich loeschend: was nicht +-- auf der Liste steht, entsteht gar nicht -- fuer Built-ins wie fuer Plugins, +-- heute wie fuer alles, was spaeter dazukommt. +-- +-- Aufruf: lua5.4 tests/test_tool_allow.lua + +local hier = arg[0]:match('(.*/)') or './' +-- VORNE anhaengen, nicht hinten. Sonst gewinnt die INSTALLIERTE Fassung unter +-- /usr/share/lua/5.4/lmcp.lua, und der Test prueft nicht den Baum, in dem er +-- liegt -- gemessen am 2026-08-08: der Test gab rot, obwohl der Code stimmte. +package.path = hier .. '../?.lua;' .. package.path + +local fehler = 0 +local function pruefe(name, bedingung, detail) + if bedingung then + print(string.format("[ok ] %s", name)) + else + fehler = fehler + 1 + print(string.format("[FEHLER] %s%s", name, detail and (" -> " .. tostring(detail)) or "")) + end +end + +local function namen(server) + local t = {} + for n in pairs(server.tools) do t[#t + 1] = n end + table.sort(t) + return t +end + +local function enthaelt(liste, wert) + for _, v in ipairs(liste) do if v == wert then return true end end + return false +end + +-- lmcp frisch laden, damit die Umgebungsvariable beim Anlegen gilt. +local function frisch() + package.loaded['lmcp'] = nil + return require('lmcp') +end + +local leer = { type = "object" } +local function nichts() return "x" end + +-- 1. Ohne die Variable aendert sich nichts (Rueckwaertsvertraeglichkeit). +-- Nur im ELTERNLAUF: im Kind ist die Liste gesetzt, dort waere die Aussage +-- falsch und der Test wuerde sich selbst widerlegen. +if os.getenv("LMCP_TOOL_ALLOW") == nil then + local lmcp = frisch() + local s = lmcp.new("probe-offen", { port = 0 }) + s:tool("room_say", "d", leer, nichts) + s:tool("shell", "d", leer, nichts) + local n = namen(s) + pruefe("ohne LMCP_TOOL_ALLOW bleibt alles registriert", + enthaelt(n, "room_say") and enthaelt(n, "shell"), table.concat(n, ",")) +end + +-- Ab hier mit Liste. lmcp liest sie beim Anlegen der Instanz, also muss sie +-- VOR lmcp.new() in der Umgebung stehen -- in Lua nur ueber einen Kindprozess +-- setzbar, deshalb startet der Test sich selbst neu. +if os.getenv("LMCP_TOOL_ALLOW") == nil then + local eigen = arg[0] + local rc = os.execute( + 'LMCP_TOOL_ALLOW="room_say,room_read,lease_acquire" lua5.4 "' .. eigen .. '" --kind') + local ok = (rc == true or rc == 0) + pruefe("Teillauf mit gesetzter Liste besteht", ok, tostring(rc)) + print(fehler .. " Pruefungen fehlgeschlagen") + os.exit(fehler == 0 and 0 or 1) +end + +-- --- Kindlauf: LMCP_TOOL_ALLOW ist gesetzt ----------------------------------- +do + local lmcp = frisch() + local s = lmcp.new("probe-eng", { port = 0 }) + + -- erlaubt + s:tool("room_say", "d", leer, nichts) + s:tool("room_read", "d", leer, nichts) + s:tool("lease_acquire", "d", leer, nichts) + -- nicht erlaubt: genau die, die die Wurzelschale ausmachten + s:tool("shell", "d", leer, nichts) + s:tool("shell_bg", "d", leer, nichts) + s:tool("write_file", "d", leer, nichts) + s:tool("edit_file", "d", leer, nichts) + s:tool("read_file", "d", leer, nichts) + + local n = namen(s) + pruefe("erlaubte Werkzeuge sind da", + enthaelt(n, "room_say") and enthaelt(n, "room_read") and enthaelt(n, "lease_acquire"), + table.concat(n, ",")) + pruefe("shell ist NICHT registriert", not enthaelt(n, "shell")) + pruefe("shell_bg ist NICHT registriert", not enthaelt(n, "shell_bg")) + pruefe("write_file ist NICHT registriert", not enthaelt(n, "write_file")) + pruefe("edit_file ist NICHT registriert", not enthaelt(n, "edit_file")) + pruefe("read_file ist NICHT registriert", not enthaelt(n, "read_file")) + pruefe("genau drei Werkzeuge uebrig", #n == 3, table.concat(n, ",")) + + -- Der Kern: `tools/list` und `tools/call` lesen DASSELBE Register. Ein + -- nicht registriertes Werkzeug ist also nicht bloss unsichtbar, es ist + -- nicht rufbar. Waere es nur aus der Liste gefiltert, bliebe es erreichbar. + pruefe("verweigertes Werkzeug ist auch nicht aufrufbar", + s.tools["shell"] == nil) + + -- Die Registrierung darf nicht werfen: ein Plugin, das ein gesperrtes + -- Werkzeug anbietet, soll weiterlaufen, nicht abstuerzen. + local ok = pcall(function() s:tool("shell", "d", leer, nichts) end) + pruefe("Registrierung eines gesperrten Werkzeugs wirft nicht", ok) + + -- Verkettung muss erhalten bleiben (tool() gibt self zurueck). + local zurueck = s:tool("shell", "d", leer, nichts) + pruefe("tool() liefert weiterhin self (verkettbar)", zurueck == s) +end + +print(fehler .. " Pruefungen fehlgeschlagen") +os.exit(fehler == 0 and 0 or 1)