Files
lmcp/tests/test_tool_allow.lua
T
Markus Fritsche 611a047bef lmcp: LMCP_TOOL_ALLOW — Erlaubnisliste je Instanz
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.
2026-08-08 13:26:05 +02:00

125 lines
5.2 KiB
Lua

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