diff --git a/Makefile b/Makefile index 213a181..079d0e0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ LUA_PATH := ./lua/?.lua;./lua/?/init.lua;; export LUA_PATH .PHONY: test examples examples-local integration check zip servers server-http server-sse server-streamable server-tcp server-udp server-graphql server-mcp \ - example-http example-sse example-streamable example-tcp example-udp \ + example-http example-sse example-streamable example-tcp example-udp example-guard \ benchmark \ example-graphql example-mcp example-cli example-text example-codemode example-provider-flow example-provider-codemode \ example-openrouter-codemode example-openrouter-codemode-chat example-openrouter-codemode-repair @@ -21,6 +21,7 @@ examples-local: $(LUA) examples/manual.lua $(LUA) examples/text.lua $(LUA) examples/cli.lua + $(LUA) examples/guard.lua $(LUA) examples/codemode.lua examples: @@ -44,6 +45,8 @@ example-cli: $(LUA) examples/cli.lua example-text: $(LUA) examples/text.lua +example-guard: + $(LUA) examples/guard.lua example-codemode: $(LUA) examples/codemode.lua example-provider-flow: diff --git a/README.md b/README.md index 9ebe2e7..e08d770 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,62 @@ client:add_manual({ This makes the tool accessible through the same canonical registry used for discovered providers. +## Client-side Guard + +Set `guard` on the client to evaluate every `client:call_tool(...)` invocation +before tool lookup, discovery, or transport dispatch. The guard can be a +function or an object with `evaluate(call)`. It receives the requested +`tool_name`, `args`, and `client`, and returns a string or table verdict. + +```lua +local client = utcp.new({ + guard = { + evaluate = function(_, call) + if call.tool_name == "delete_account" then + return { decision = "review", reason = "human approval required" } + end + return "allow" + end, + }, +}) +``` + +The supported decisions are `allow`, `deny`, `review`, and `error`. Only +`allow` reaches the underlying HTTP, CLI, MCP, or other native transport, and +each allowed `call_tool` invocation dispatches once. The other decisions, an +invalid verdict, or an evaluator failure return a structured UTCP error and do +not dispatch a tool call. + +A `review` decision requires an `approve(call, review_verdict)` method. It must +return `allow` before the tool is dispatched; without it, the client returns +`guard_review_required` and makes no transport call. + +```lua +guard = { + evaluate = function(_, call) + return {decision = "review", reason = "human approval required"} + end, + approve = function(_, call, review) + -- Present review.reason to an authorized human here. + return {decision = "allow"} + end, +} +``` + +For deliberately safe, client-owned tools, `bypass_tools` can be an exact +allowlist (an array or `{[tool_name] = true}` map). A bypassed tool skips guard +evaluation and dispatches normally; use this only for tools whose safety does +not depend on the Guard policy. + +```lua +guard = { + bypass_tools = {"healthcheck", "local_status"}, + evaluate = function(_, call) + return {decision = "deny", reason = "not approved"} + end, +} +``` + ## Streaming Streaming tools can be consumed incrementally: diff --git a/examples/README.md b/examples/README.md index ba0fbfe..55b118c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,6 +35,18 @@ Server endpoints: The servers are implemented under `examples/servers/` and use only Python's standard library. +## Guard example + +`guard.lua` keeps policy enforcement in the client. It explicitly bypasses a +safe local profile read and account-summary tool, returning both results without +policy evaluation. It denies an account deletion; for a payment, it requires an +approval callback, then dispatches and returns the result. No denied or +unapproved review call reaches a native transport. + +```bash +make example-guard +``` + # CodeMode example `codemode.lua` demonstrates the CodeMode execution model. Generated Lua code diff --git a/examples/guard.lua b/examples/guard.lua new file mode 100644 index 0000000..e3ef165 --- /dev/null +++ b/examples/guard.lua @@ -0,0 +1,85 @@ +package.path = './lua/?.lua;./lua/?/init.lua;' .. package.path + +local utcp = require('utcp') + +local client = utcp.new({ + guard = { + -- These local reads are explicitly safe and do not require evaluation. + bypass_tools = {'read_profile', 'get_account_summary'}, + evaluate = function(_, call) + if call.tool_name == 'delete_account' then + return {decision = 'deny', reason = 'account deletion is not permitted'} + end + + if call.tool_name == 'send_payment' then + return {decision = 'review', reason = 'payment needs human approval'} + end + + return {decision = 'deny', reason = 'tool is not approved'} + end, + approve = function(_, call, review) + assert(call.tool_name == 'send_payment') + assert(review.decision == 'review') + -- Replace this with a prompt to an authorized human in an application. + return {decision = 'allow'} + end, + }, +}) + +client:add_manual({ + tools = { + { + name = 'read_profile', + description = 'Read a profile from a local fixture', + tool_call_template = { + call_template_type = 'text', + path = 'examples/tool-result.json', + }, + }, + { + name = 'delete_account', + description = 'Delete an account', + tool_call_template = { + call_template_type = 'text', + path = 'examples/tool-result.json', + }, + }, + { + name = 'get_account_summary', + description = 'Read an account summary', + tool_call_template = { + call_template_type = 'text', + path = 'examples/tool-result.json', + }, + }, + { + name = 'send_payment', + description = 'Send a payment', + tool_call_template = { + call_template_type = 'text', + path = 'examples/tool-result.json', + }, + }, + }, +}) + +-- This call dispatches even though the Guard's default decision is deny. +local profile, profile_err = client:call_tool('read_profile', {}) +assert(profile, profile_err) +print('bypassed:', profile.message) + +-- This guarded-client tool is on bypass_tools, so it returns without evaluation. +local summary, summary_err = client:call_tool('get_account_summary', {}) +assert(summary, summary_err) +print('bypassed result:', summary.message) + +-- The Guard returns review, approval returns allow, then the tool dispatches. +local payment, payment_err = client:call_tool('send_payment', {}) +assert(payment, payment_err) +print('approved result:', payment.message) + +for _,tool_name in ipairs({'delete_account'}) do + local result, err = client:call_tool(tool_name, {}) + assert(result == nil and utcp.errors.is(err), 'expected a structured Guard error') + print(tool_name .. ':', err.kind, err.message) +end diff --git a/examples/run_examples.py b/examples/run_examples.py index 86b342d..768e844 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -23,6 +23,7 @@ "examples/manual.lua", "examples/text.lua", "examples/cli.lua", + "examples/guard.lua", "examples/codemode.lua", "examples/http.lua", "examples/sse.lua", diff --git a/lua-utcp-1.3-1.rockspec b/lua-utcp-1.3-1.rockspec index 8d85ea3..167288d 100644 --- a/lua-utcp-1.3-1.rockspec +++ b/lua-utcp-1.3-1.rockspec @@ -15,6 +15,7 @@ build = { ["utcp.auth"] = "lua/utcp/auth.lua", ["utcp.codemode"] = "lua/utcp/codemode.lua", ["utcp.provider"] = "lua/utcp/provider.lua", + ["utcp.guard"] = "lua/utcp/guard.lua", ["utcp.transports"] = "lua/utcp/transports/init.lua", ["utcp.transports.http"] = "lua/utcp/transports/http.lua", ["utcp.transports.sse"] = "lua/utcp/transports/sse.lua", diff --git a/lua/utcp/client.lua b/lua/utcp/client.lua index 8201343..b873f71 100644 --- a/lua/utcp/client.lua +++ b/lua/utcp/client.lua @@ -1,4 +1,4 @@ -local json=require('utcp.json'); local Registry=require('utcp.registry'); local transports=require('utcp.transports'); local errors=require('utcp.errors') +local json=require('utcp.json'); local Registry=require('utcp.registry'); local transports=require('utcp.transports'); local errors=require('utcp.errors'); local Guard=require('utcp.guard') local Client={}; Client.__index=Client local aliases={streamable_http='streamable',streamable='streamable',http='http',sse='sse',tcp='tcp',udp='udp',cli='cli',text='text',graphql='graphql',mcp='mcp'} function Client.new(cfg) @@ -79,6 +79,12 @@ function Client:find_tool(name) return nil,'unknown UTCP tool: '..tostring(name) end function Client:call_tool(name,args) + local call_args=args or {} + local allowed,guard_err=Guard.evaluate(self.config.guard,{tool_name=name,args=call_args,client=self}) + if not allowed then + return nil,guard_err + end + local tool,p=self:find_tool(name) -- If the tool is not registered yet, try discovering provider manuals. @@ -165,13 +171,13 @@ function Client:call_tool(name,args) if typ=='mcp' then return transport:call_tool( tpl.name or name, - args or {} + call_args ) end return transport:call( tpl, - args or {} + call_args ) end function Client:call_tool_stream(name,args,on_event) diff --git a/lua/utcp/guard.lua b/lua/utcp/guard.lua new file mode 100644 index 0000000..1fce614 --- /dev/null +++ b/lua/utcp/guard.lua @@ -0,0 +1,198 @@ +local errors = require('utcp.errors') + +local M = {} + +local function guard_error(kind, message, call, verdict) + return errors.new(kind, message, { + tool_name = call.tool_name, + args = call.args, + verdict = verdict, + }) +end + +local function evaluator_for(guard) + if type(guard) == 'function' then + return function(call) + return guard(call) + end + end + + if type(guard) == 'table' and type(guard.evaluate) == 'function' then + return function(call) + return guard:evaluate(call) + end + end + + return nil +end + +local function approver_for(guard) + if type(guard) == 'table' and type(guard.approve) == 'function' then + return function(call, review) + return guard:approve(call, review) + end + end + + return nil +end + +local function bypasses(guard, call) + if type(guard) ~= 'table' or guard.bypass_tools == nil then + return false + end + + if type(guard.bypass_tools) ~= 'table' then + return nil, 'UTCP guard bypass_tools must be a table' + end + + if guard.bypass_tools[call.tool_name] == true then + return true + end + + for _,tool_name in ipairs(guard.bypass_tools) do + if tool_name == call.tool_name then + return true + end + end + + return false +end + +-- Evaluate a client-side tool-call guard. A guard may be a function or a table +-- with an evaluate(call) method. It must return one of the documented verdicts: +-- +-- "allow" | { decision = "allow" } +-- "deny" | { decision = "deny", reason = "..." } +-- "review"| { decision = "review", reason = "..." } +-- "error" | { decision = "error", reason = "..." } +-- +-- A review is blocked unless guard.approve(call, review_verdict) returns an +-- "allow" verdict. Invalid results and callback failures fail closed as guard +-- errors. +function M.evaluate(guard, call) + if guard == nil then + return true + end + + local bypass, bypass_err = bypasses(guard, call) + if bypass_err then + return nil, guard_error('guard_error', bypass_err, call) + end + if bypass then + return true + end + + local evaluate = evaluator_for(guard) + if not evaluate then + return nil, guard_error( + 'guard_error', + 'UTCP guard must be a function or expose evaluate(call)', + call + ) + end + + local ok, verdict = pcall(evaluate, call) + if not ok then + return nil, guard_error( + 'guard_error', + 'UTCP guard evaluation failed: ' .. tostring(verdict), + call + ) + end + + local decision = type(verdict) == 'table' and verdict.decision or verdict + local reason = type(verdict) == 'table' and verdict.reason or nil + + if decision == 'allow' then + return true + end + + if decision == 'deny' then + return nil, guard_error( + 'guard_denied', + reason or 'tool call denied by guard', + call, + verdict + ) + end + + if decision == 'review' then + local approve = approver_for(guard) + if not approve then + return nil, guard_error( + 'guard_review_required', + reason or 'tool call requires guard review', + call, + verdict + ) + end + + local approved, approval = pcall(approve, call, verdict) + if not approved then + return nil, guard_error( + 'guard_error', + 'UTCP guard approval failed: ' .. tostring(approval), + call, + verdict + ) + end + + local approval_decision = type(approval) == 'table' and approval.decision or approval + local approval_reason = type(approval) == 'table' and approval.reason or nil + if approval_decision == 'allow' then + return true + end + + if approval_decision == 'deny' then + return nil, guard_error( + 'guard_denied', + approval_reason or reason or 'tool call denied during guard approval', + call, + approval + ) + end + + if approval_decision == 'review' then + return nil, guard_error( + 'guard_review_required', + approval_reason or reason or 'tool call still requires guard review', + call, + approval + ) + end + + if approval_decision == 'error' then + return nil, guard_error( + 'guard_error', + approval_reason or 'guard approval could not evaluate tool call', + call, + approval + ) + end + + return nil, guard_error( + 'guard_error', + 'UTCP guard approval returned an invalid decision: ' .. tostring(approval_decision), + call, + approval + ) + end + + if decision == 'error' then + return nil, guard_error( + 'guard_error', + reason or 'guard could not evaluate tool call', + call, + verdict + ) + end + + return nil, guard_error( + 'guard_error', + 'UTCP guard returned an invalid decision: ' .. tostring(decision), + call, + verdict + ) +end + +return M diff --git a/lua/utcp/init.lua b/lua/utcp/init.lua index 2f96dce..a1ce3d9 100644 --- a/lua/utcp/init.lua +++ b/lua/utcp/init.lua @@ -1,5 +1,5 @@ local Client=require('utcp.client') -local M={Client=Client, Registry=require('utcp.registry'), errors=require('utcp.errors'), json=require('utcp.json'), transports=require('utcp.transports'), codemode=require('utcp.codemode'), provider=require('utcp.provider')} +local M={Client=Client, Registry=require('utcp.registry'), errors=require('utcp.errors'), json=require('utcp.json'), transports=require('utcp.transports'), codemode=require('utcp.codemode'), provider=require('utcp.provider'), guard=require('utcp.guard')} function M.new(cfg) return Client.new(cfg) end function M.load_provider(path) return M.provider.load(path) end return M diff --git a/tests/test_client_lookup.lua b/tests/test_client_lookup.lua index 3ad108f..6f14c3e 100644 --- a/tests/test_client_lookup.lua +++ b/tests/test_client_lookup.lua @@ -1,5 +1,6 @@ package.path = './lua/?.lua;./lua/?/init.lua;'..package.path local utcp = require('utcp') +local errors = require('utcp.errors') local client = utcp.new({}) local provider = {name='example', transport='http', url='http://127.0.0.1:8080'} client:add_provider(provider) @@ -48,4 +49,115 @@ cached_client:add_manual({tools={{name='cached',tool_call_template={call_templat assert(cached_client:call_tool('cached', {n=3}).n == 3) assert(text_constructions == 2, 'replacing a tool must invalidate its cached transport') transports.text.new = original_text_new + +local original_guard_transport_new = { + http = transports.http.new, + cli = transports.cli.new, + mcp = transports.mcp.new, +} +local guard_transport_calls = {http=0,cli=0,mcp=0} +local guard_transport_constructions = {http=0,cli=0,mcp=0} +for _,typ in ipairs({'http','cli','mcp'}) do + transports[typ].new = function() + guard_transport_constructions[typ] = guard_transport_constructions[typ] + 1 + if typ == 'mcp' then + return { + call_tool = function(_, _, args) + guard_transport_calls[typ] = guard_transport_calls[typ] + 1 + return {called=guard_transport_calls[typ],args=args} + end, + } + end + return { + call = function(_, _, args) + guard_transport_calls[typ] = guard_transport_calls[typ] + 1 + return {called=guard_transport_calls[typ],args=args} + end, + } + end +end + +local function guarded_client(verdict, tool_name, transport) + local guarded = utcp.new({ + guard = { + evaluate = function(_, call) + assert(call.tool_name == tool_name) + assert(call.args.value == 7) + return verdict + end, + }, + }) + guarded:add_manual({tools={{name=tool_name,tool_call_template={call_template_type=transport}}}}) + return guarded +end + +for _,case in ipairs({ + {verdict={decision='deny',reason='blocked by policy'},kind='guard_denied',tool='guarded_http',transport='http'}, + {verdict={decision='review',reason='approval required'},kind='guard_review_required',tool='guarded_cli',transport='cli'}, + {verdict={decision='error',reason='guard unavailable'},kind='guard_error',tool='guarded_mcp',transport='mcp'}, +}) do + local result, guard_err = guarded_client(case.verdict, case.tool, case.transport):call_tool(case.tool, {value=7}) + assert(result == nil) + assert(errors.is(guard_err) and guard_err.kind == case.kind) +end +for _,typ in ipairs({'http','cli','mcp'}) do + assert(guard_transport_constructions[typ] == 0, 'non-allow guard decisions must not construct a '..typ..' transport') + assert(guard_transport_calls[typ] == 0, 'non-allow guard decisions must not dispatch a '..typ..' transport call') +end + +local allowed_result, allowed_err = guarded_client({decision='allow'}, 'allowed_http', 'http'):call_tool('allowed_http', {value=7}) +assert(allowed_result and allowed_result.called == 1, allowed_err) +assert(guard_transport_constructions.http == 1, 'an allowed call must construct the HTTP transport once') +assert(guard_transport_calls.http == 1, 'an allowed call must dispatch exactly once') + +local bypass_evaluations = 0 +local bypass_client = utcp.new({ + guard = { + bypass_tools = {'bypass_http'}, + evaluate = function() + bypass_evaluations = bypass_evaluations + 1 + return {decision='deny'} + end, + }, +}) +bypass_client:add_manual({tools={{name='bypass_http',tool_call_template={call_template_type='http'}}}}) +local bypass_result, bypass_err = bypass_client:call_tool('bypass_http', {value=7}) +assert(bypass_result and bypass_result.called == 2, bypass_err) +assert(bypass_evaluations == 0, 'bypassed tools must not evaluate the guard') +assert(guard_transport_calls.http == 2, 'a bypassed tool must dispatch exactly once') + +local review_evaluations, approval_requests = 0, 0 +local approved_review_client = utcp.new({ + guard = { + evaluate = function() + review_evaluations = review_evaluations + 1 + return {decision='review',reason='human approval required'} + end, + approve = function(_, call, review) + approval_requests = approval_requests + 1 + assert(call.tool_name == 'approved_http') + assert(review.decision == 'review') + return {decision='allow'} + end, + }, +}) +approved_review_client:add_manual({tools={{name='approved_http',tool_call_template={call_template_type='http'}}}}) +local approved_result, approved_err = approved_review_client:call_tool('approved_http', {value=7}) +assert(approved_result and approved_result.called == 3, approved_err) +assert(review_evaluations == 1 and approval_requests == 1, 'review calls must request approval once') +assert(guard_transport_calls.http == 3, 'an approved review must dispatch exactly once') + +local guard_failure_client = utcp.new({ + guard = function() + error('evaluator crashed') + end, +}) +guard_failure_client:add_manual({tools={{name='failed_guard',tool_call_template={call_template_type='http'}}}}) +local failed_result, failed_guard_err = guard_failure_client:call_tool('failed_guard', {value=7}) +assert(failed_result == nil) +assert(errors.is(failed_guard_err) and failed_guard_err.kind == 'guard_error') +assert(guard_transport_calls.http == 3, 'a guard failure must not dispatch a transport call') +for typ,new in pairs(original_guard_transport_new) do + transports[typ].new = new +end print('lua-utcp client lookup tests: ok')