Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions examples/guard.lua
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions examples/run_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lua-utcp-1.3-1.rockspec
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions lua/utcp/client.lua
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading