From 611a047befd1a3f6afb894d7ee32356fb2ae83c0 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 8 Aug 2026 13:26:05 +0200 Subject: [PATCH] =?UTF-8?q?lmcp:=20LMCP=5FTOOL=5FALLOW=20=E2=80=94=20Erlau?= =?UTF-8?q?bnisliste=20je=20Instanz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine lmcp-Instanz konnte ihren Werkzeugsatz bisher nur ERWEITERN. tools.d-Dateien fuegen hinzu; der Grundstock aus server.lua bringt shell, shell_bg, write_file, edit_file, read_file, fetch und web_search mit, und eine Plugin-Datei kann nichts wegnehmen. Gemessen am 2026-08-08 im bullpen: jeder Agent-Container haelt den Raum-Token, also hatte JEDER von ihnen eine Wurzelschale im Raum-Container. Nachgewiesen aus dem foreman-Container -> uid=0(root), hostname room, Schreibzugriff auf room.jsonl. Das ist keine Einbruchsluecke (alle Container sind eine Sicherheitsdomaene), aber es macht jede Aussage ueber Rollentrennung unbelegbar: der Orchestrator, der laut Entwurf KEINE Schale haben darf ("Raumtext ist nicht vertrauenswuerdig"), hat eine — und hat sie im ersten Durchlauf unaufgefordert benutzt, um die Abnahme seiner eigenen Koordination auszufuehren. GEPRUEFT WIRD BEI DER REGISTRIERUNG, nicht nachtraeglich loeschend. Die Alternative waere eine Plugin-Datei, die nach dem Laden aus server.tools entfernt — die muss jeden kuenftigen Grundstock-Eintrag kennen und ist damit wieder eine Liste, die jemand pflegen muss. Genau solche Listen laufen auseinander (siehe PRIVILEGED, zwei Kopien in zwei Dateien). An der Registrierung ist die Regel EINE Aussage: was nicht auf der Liste steht, entsteht nicht — Built-ins wie Plugins, heute wie fuer alles, was morgen dazukommt. Rueckwaertsvertraeglich: ohne LMCP_TOOL_ALLOW aendert sich nichts. Verweigern ist stumm und wirft nicht, damit ein Plugin, das ein gesperrtes Werkzeug anbietet, weiterlaeuft statt abzustuerzen; tool() bleibt verkettbar. tests/test_tool_allow.lua weist es in beide Richtungen nach: Rueckgabe 0 gegen diese Fassung, Rueckgabe 1 mit sieben Fehlschlaegen gegen die vorige. Der Test prueft ausdruecklich, dass ein verweigertes Werkzeug auch nicht AUFRUFBAR ist — tools/list und tools/call lesen dasselbe Register, ein reiner Anzeigefilter waere wertlos gewesen. Zwei Fallen im Test selbst, als Kommentar festgehalten, weil sie mich zwei Runden gekostet haben: package.path muss VORNE ergaenzt werden, sonst gewinnt die installierte /usr/share/lua/5.4/lmcp.lua und der Test misst den falschen Baum; und die Rueckwaerts-Pruefung darf nur im Elternlauf laufen, im Kind ist die Liste gesetzt. --- lmcp.lua | 20 ++++++ tests/test_tool_allow.lua | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/test_tool_allow.lua 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)