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 @@ -8,7 +8,7 @@ LUA_PATH := ./lua/?.lua;./lua/?/init.lua;$(LUAROCKS_LUA_PATH);;
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-guard \
example-http example-sse example-streamable example-tcp example-udp example-guard example-hol-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 @@ -22,6 +22,7 @@ test:
$(LUA) tests/test_template.lua
$(LUA) tests/test_transports.lua
$(LUA) tests/test_cli.lua
$(LUA) tests/test_hol_guard.lua

examples-local:
$(LUA) examples/manual.lua
Expand Down Expand Up @@ -53,6 +54,8 @@ example-text:
$(LUA) examples/text.lua
example-guard:
$(LUA) examples/guard.lua
example-hol-guard:
$(LUA) examples/hol_guard.lua
example-codemode:
$(LUA) examples/codemode.lua
example-provider-flow:
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,45 @@ guard = {
}
```

### HOL Guard command-safety adapter

`utcp.guards.hol_guard` adapts [HOL Guard](https://github.com/hashgraph-online/hol-guard)'s
side-effect-free `hol-guard command test <command> --json` classifier to the
client guard interface. It only classifies tool calls that can be represented
as a shell command; it does not replace HOL Guard's native agent harnesses or
approval center.

Install HOL Guard separately, then configure a command extractor. The adapter
fails closed for a missing executable, malformed output, unknown result, or an
unmapped tool call. Set `unmapped_decision` explicitly only when those calls
are protected elsewhere.

```lua
local utcp = require("utcp")

local client = utcp.new({
guard = utcp.guards.hol_guard.new({
command_for = function(call)
if call.tool_name == "shell" then
return call.args.command
end
end,
unmapped_decision = "deny",
approve = function(call, review)
-- Present review.reason to an authorized human here.
return {decision = "allow"}
end,
}),
})
```

HOL Guard 3's `classification.explicitly_benign` result dispatches the call;
`review` or `block` statuses use the optional application-owned `approve`
callback or deny it. A non-benign `no_match` result is held for review rather
than treated as safe. By default the adapter reads `args.command`; use
`command_for` for a different tool schema, and `executable` to provide an
absolute HOL Guard path.

## Streaming

Streaming tools can be consumed incrementally:
Expand Down
29 changes: 29 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ unapproved review call reaches a native transport.
make example-guard
```

## HOL Guard example

`hol_guard.lua` wraps a real CLI tool call with the HOL Guard adapter. It
classifies the requested shell command before UTCP dispatches it: safe commands
run, blocked commands do not reach the CLI transport, and review decisions ask
the local user to type `ALLOW`.

Install [HOL Guard](https://github.com/hashgraph-online/hol-guard) first, then
run the example from the repository root:

```bash
make example-hol-guard
```

It checks `git status --short` by default. Override the executable or command
only when you intend to test it:

```bash
HOL_GUARD_BIN=/absolute/path/to/hol-guard \
HOL_GUARD_EXAMPLE_COMMAND='git clean -fd' \
make example-hol-guard
```

The command runs only after the adapter returns `allow`, or the local user
approves a `review` decision. Replace the example callback with an authenticated
approval workflow in production. Review commands require an interactive terminal
and the exact uppercase response `ALLOW`; non-interactive runs intentionally deny
the command rather than approving it implicitly.

# CodeMode example

`codemode.lua` demonstrates the CodeMode execution model. Generated Lua code
Expand Down
72 changes: 72 additions & 0 deletions examples/hol_guard.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package.path = './lua/?.lua;./lua/?/init.lua;' .. package.path

local utcp = require('utcp')

local command = os.getenv('HOL_GUARD_EXAMPLE_COMMAND') or 'git status --short'
local executable = os.getenv('HOL_GUARD_BIN') or 'hol-guard'

local function request_approval(call, review)
io.write(('HOL Guard review required for: %s\n'):format(call.args.command))
io.write(('Reason: %s\n'):format(review.reason or 'No reason provided'))
io.write('Type ALLOW to run this command: ')

if io.read('*l') == 'ALLOW' then
return {decision = 'allow'}
end

return {decision = 'deny', reason = 'command was not approved'}
end

local client = utcp.new({
guard = utcp.guards.hol_guard.new({
executable = executable,
command_for = function(call)
if call.tool_name == 'shell' then
return call.args.command
end
end,
-- This example exposes only a shell tool. Other calls are denied rather
-- than silently skipping HOL Guard classification.
unmapped_decision = 'deny',
approve = request_approval,
}),
})

client:add_manual({
manual_version = '1.0',
utcp_version = '1.0',
tools = {
{
name = 'shell',
description = 'Run a command after HOL Guard command-safety classification',
inputs = {
type = 'object',
properties = {
command = {type = 'string'},
},
required = {'command'},
},
tool_call_template = {
call_template_type = 'cli',
-- The CLI transport quotes UTCP arguments. Pass the classified command
-- as sh's single -c argument instead of treating it as an executable
-- path with embedded spaces.
command = 'sh -c UTCP_ARG_command_UTCP_END',
output_type = 'text',
},
},
},
})

print('Classifying with HOL Guard:', command)
local result, err = client:call_tool('shell', {command = command})
if not result then
io.stderr:write(('Command was not dispatched (%s): %s\n'):format(
err.kind or 'error',
err.message or tostring(err)
))
os.exit(1)
end

print('Command output:')
print(result)
6 changes: 4 additions & 2 deletions lua-utcp-1.5-1.rockspec → lua-utcp-1.6-1.rockspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package = "lua-utcp"
version = "1.5-1"
source = { url = "https://github.com/universal-tool-calling-protocol/lua-utcp/archive/refs/tags/v1.5.0.tar.gz" }
version = "1.6-1"
source = { url = "https://github.com/universal-tool-calling-protocol/lua-utcp/archive/refs/tags/v1.6.0.tar.gz" }
description = { summary = "Universal Tool Calling Protocol client for Lua", homepage = "https://utcp.io", license = "MPL-2.0" }
dependencies = { "lua >= 5.3", "luasocket >= 3.1", "lua-cjson >= 2.1" }
build = {
Expand All @@ -16,6 +16,8 @@ build = {
["utcp.codemode"] = "lua/utcp/codemode.lua",
["utcp.provider"] = "lua/utcp/provider.lua",
["utcp.guard"] = "lua/utcp/guard.lua",
["utcp.guards"] = "lua/utcp/guards/init.lua",
["utcp.guards.hol_guard"] = "lua/utcp/guards/hol_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
Binary file renamed lua-utcp-1.5-1.src.rock → lua-utcp-1.6-1.src.rock
Binary file not shown.
Loading
Loading