This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 4d89fb7438125c2538698ab71582dac4e9f0b709 Author: Yang Luo <[email protected]> AuthorDate: Sat Sep 5 00:29:14 2026 +0800 feat(permissions): refuse a tool call in openclaw through a plugin hook --- agentpatch/openclaw.go | 159 ++++++++++++++++++++++++++++++++++++++---- agentpatch/openclaw_plugin.js | 81 +++++++++++++++++++++ 2 files changed, 228 insertions(+), 12 deletions(-) diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go index 038fb3e..7a50dc7 100644 --- a/agentpatch/openclaw.go +++ b/agentpatch/openclaw.go @@ -32,12 +32,21 @@ const ( openclawConfigFile = "openclaw.json" ) +// openclawPluginId is the plugin that decides a tool call. It is a second +// artifact rather than a change to the hook beside it: the event stream a hook +// listens on is not something a session waits for, and before_tool_call, the +// one seam that is, is only reachable from a plugin. +const openclawPluginId = "casbin-gateway-agent-permissions" + //go:embed openclaw_hook.js var openclawHookHandler string //go:embed openclaw_hook.md var openclawHookManifest string +//go:embed openclaw_plugin.js +var openclawPluginEntry string + type openclawPatcher struct{} func init() { @@ -61,6 +70,10 @@ func (p openclawPatcher) Patch(target Target) error { if err != nil { return err } + plugin, err := renderOpenclawPlugin(token) + if err != nil { + return err + } return Apply(target, func(changes *ChangeSet) error { if err := changes.MkdirAll(layout.hookDir); err != nil { return err @@ -71,7 +84,10 @@ func (p openclawPatcher) Patch(target Target) error { if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(handler), 0o644); err != nil { return err } - return enableOpenclawHook(changes, layout.configPath) + if err := writeOpenclawPlugin(changes, layout.pluginDir, plugin); err != nil { + return err + } + return enableOpenclawEntries(changes, layout.configPath) }) } @@ -103,12 +119,30 @@ func (p openclawPatcher) Status(target Target) (Status, error) { if !strings.Contains(string(handler), jsonString(current)) { return Status{Detail: "OpenClaw hook needs refresh"}, nil } - enabled, err := openclawHookEnabled(layout.configPath) + + // The plugin beside it is what refuses a tool call, and a patch missing it + // is the one an earlier Gateway wrote, before there was one. + decision, err := decisionURL() + if err != nil { + return Status{}, err + } + entry, err := os.ReadFile(filepath.Join(layout.pluginDir, "index.js")) + if err != nil { + if os.IsNotExist(err) { + return Status{Detail: "OpenClaw permission plugin needs refresh"}, nil + } + return Status{}, err + } + if !strings.Contains(string(entry), jsonString(decision)) { + return Status{Detail: "OpenClaw permission plugin needs refresh"}, nil + } + + enabled, err := openclawEntriesEnabled(layout.configPath) if err != nil { return Status{}, err } if !enabled { - return Status{Detail: "OpenClaw hook is installed but disabled"}, nil + return Status{Detail: "OpenClaw hook and plugin are installed but not both enabled"}, nil } if !IsApplied(target) { return Status{Patched: true, Detail: "OpenClaw hook was installed outside Gateway"}, nil @@ -116,15 +150,21 @@ func (p openclawPatcher) Status(target Target) (Status, error) { return Status{Patched: true, Detail: "OpenClaw hook active after gateway restart"}, nil } +// Decides: the plugin's before_tool_call handler returns block, which OpenClaw +// treats as terminal and refuses the call on. +func (openclawPatcher) Decides() bool { return true } + func (openclawPatcher) PatchNotice(patched bool) (string, string) { if patched { - return "Removes Gateway's audit-only OpenClaw hook.", "Restart the OpenClaw gateway after removing the hook." + return "Removes Gateway's OpenClaw hook and the plugin that checks each tool call.", "Restart the OpenClaw gateway after removing them." } - return "Installs an audit-only OpenClaw hook.", "Restart the OpenClaw gateway to load the hook." + return "Installs an OpenClaw hook that observes events, and a plugin that refuses a tool call this agent's permissions do not allow; an agent nobody has restricted is never held up.", + "Restart the OpenClaw gateway to load them." } type openclawLayout struct { hookDir string + pluginDir string configPath string } @@ -135,7 +175,10 @@ func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) { } stateDir := filepath.Join(home, openclawStateDir) return openclawLayout{ - hookDir: filepath.Join(stateDir, "hooks", openclawHookName), + hookDir: filepath.Join(stateDir, "hooks", openclawHookName), + // "extensions" under the state directory is one of the roots OpenClaw + // discovers plugins from, so nothing has to be added to load.paths. + pluginDir: filepath.Join(stateDir, "extensions", openclawPluginId), configPath: filepath.Join(stateDir, openclawConfigFile), }, nil } @@ -159,7 +202,68 @@ func renderOpenclawHandler(target Target, ingestToken string) (string, error) { return handler, nil } -func enableOpenclawHook(changes *ChangeSet, configPath string) error { +// renderOpenclawPlugin fills in the plugin entry. It carries no records URL: +// the hook beside it does the reporting, and this one only asks. +func renderOpenclawPlugin(ingestToken string) (string, error) { + url, err := decisionURL() + if err != nil { + return "", err + } + replacements := map[string]string{ + "__CASBIN_GATEWAY_PLUGIN_ID__": jsonString(openclawPluginId), + "__CASBIN_GATEWAY_AGENT__": jsonString("openclaw"), + "__CASBIN_GATEWAY_DECISION_URL__": jsonString(url), + "__CASBIN_GATEWAY_INGEST_TOKEN__": jsonString(ingestToken), + "__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": jsonString(agentmonitor.IngestTokenHeader), + } + entry := openclawPluginEntry + for placeholder, value := range replacements { + entry = strings.ReplaceAll(entry, placeholder, value) + } + return entry, nil +} + +// writeOpenclawPlugin lays out the three files OpenClaw reads a plugin from: +// the manifest it identifies it by, the package.json naming the entry module, +// and the module itself. +func writeOpenclawPlugin(changes *ChangeSet, pluginDir string, entry string) error { + if err := changes.MkdirAll(pluginDir); err != nil { + return err + } + manifest, err := json.MarshalIndent(map[string]any{ + "id": openclawPluginId, + "name": "Casbin Gateway Agent Permissions", + "description": "Refuses a tool call the agent's Casbin Gateway permissions do not allow", + }, "", " ") + if err != nil { + return err + } + pkg, err := json.MarshalIndent(map[string]any{ + "name": openclawPluginId, + "version": "1.0.0", + "private": true, + "type": "module", + // Discovery refuses a package that does not name its entry here. + "openclaw": map[string]any{"extensions": []string{"./index.js"}}, + }, "", " ") + if err != nil { + return err + } + for name, data := range map[string][]byte{ + "openclaw.plugin.json": append(manifest, '\n'), + "package.json": append(pkg, '\n'), + "index.js": []byte(entry), + } { + if err := changes.WriteFile(filepath.Join(pluginDir, name), data, 0o644); err != nil { + return err + } + } + return nil +} + +// enableOpenclawEntries turns on both of Gateway's artifacts in one write: the +// audit hook and the plugin that decides a tool call. +func enableOpenclawEntries(changes *ChangeSet, configPath string) error { if err := changes.MkdirAll(filepath.Dir(configPath)); err != nil { return err } @@ -167,6 +271,7 @@ func enableOpenclawHook(changes *ChangeSet, configPath string) error { if err != nil { return err } + hooks, err := ensureObject(config, "hooks") if err != nil { return err @@ -175,16 +280,31 @@ func enableOpenclawHook(changes *ChangeSet, configPath string) error { if err != nil { return err } - entries, err := ensureObject(internal, "entries") + hookEntries, err := ensureObject(internal, "entries") if err != nil { return err } - entry, err := ensureObject(entries, openclawHookName) + hookEntry, err := ensureObject(hookEntries, openclawHookName) if err != nil { return err } internal["enabled"] = true - entry["enabled"] = true + hookEntry["enabled"] = true + + plugins, err := ensureObject(config, "plugins") + if err != nil { + return err + } + pluginEntries, err := ensureObject(plugins, "entries") + if err != nil { + return err + } + pluginEntry, err := ensureObject(pluginEntries, openclawPluginId) + if err != nil { + return err + } + pluginEntry["enabled"] = true + updated, err := json.MarshalIndent(config, "", " ") if err != nil { return err @@ -192,7 +312,9 @@ func enableOpenclawHook(changes *ChangeSet, configPath string) error { return changes.WriteFile(configPath, append(updated, '\n'), 0o600) } -func openclawHookEnabled(configPath string) (bool, error) { +// openclawEntriesEnabled reports that both artifacts are switched on. Either +// one off is a half-installed patch, and saying so is what offers the repair. +func openclawEntriesEnabled(configPath string) (bool, error) { config, _, exists, err := readJSONConfig(configPath) if err != nil || !exists { return false, err @@ -210,7 +332,20 @@ func openclawHookEnabled(configPath string) (bool, error) { return false, nil } entry, ok := objectAt(entries[openclawHookName]) - return ok && entry["enabled"] == true, nil + if !ok || entry["enabled"] != true { + return false, nil + } + + plugins, ok := objectAt(config["plugins"]) + if !ok { + return false, nil + } + pluginEntries, ok := objectAt(plugins["entries"]) + if !ok { + return false, nil + } + pluginEntry, ok := objectAt(pluginEntries[openclawPluginId]) + return ok && pluginEntry["enabled"] == true, nil } func ensureObject(parent map[string]any, key string) (map[string]any, error) { diff --git a/agentpatch/openclaw_plugin.js b/agentpatch/openclaw_plugin.js new file mode 100644 index 0000000..cd554e5 --- /dev/null +++ b/agentpatch/openclaw_plugin.js @@ -0,0 +1,81 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Gateway's OpenClaw plugin. It registers one hook and nothing else: +// before_tool_call, which is the only seam OpenClaw waits on before running a +// tool. The audit hook beside it, under ~/.openclaw/hooks, still does the +// reporting - the event stream it listens on is not something a session waits +// for, so it can never refuse anything. + +const AGENT = __CASBIN_GATEWAY_AGENT__; +const DECISION_URL = __CASBIN_GATEWAY_DECISION_URL__; +const INGEST_TOKEN = __CASBIN_GATEWAY_INGEST_TOKEN__; +const INGEST_TOKEN_HEADER = __CASBIN_GATEWAY_INGEST_TOKEN_HEADER__; +// The tool call waits on the verdict, so a Gateway that is slow to answer is +// treated as one that is not running. +const DECIDE_TIMEOUT_MS = 2000; + +// decide asks Gateway whether a tool call may go ahead, answering with the +// reason it may not. Everything that can go wrong answers "": a plugin that +// cannot get a verdict must not stop OpenClaw working. +async function decide(toolName, sessionKey, toolCallId) { + if (!DECISION_URL || !toolName) return ""; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DECIDE_TIMEOUT_MS); + const headers = { "Content-Type": "application/json" }; + if (INGEST_TOKEN) headers[INGEST_TOKEN_HEADER] = INGEST_TOKEN; + const response = await fetch(DECISION_URL, { + method: "POST", + headers, + body: JSON.stringify({ + agent: AGENT, + tool: toolName, + sessionKey: sessionKey || "", + toolUseId: toolCallId || "", + }), + signal: controller.signal, + }).finally(() => clearTimeout(timer)); + const answer = await response.json(); + if (answer?.status !== "ok" || answer?.data?.allow !== false) return ""; + return answer.data.reason || `the permissions of agent ${AGENT} do not allow ${toolName}`; + } catch { + return ""; + } +} + +// The plugin entry. definePluginEntry is not importable from outside the +// OpenClaw bundle, so this is the object it would have built: a permissive +// config schema, since nothing here is configured from openclaw.json. +export default { + id: __CASBIN_GATEWAY_PLUGIN_ID__, + name: "Casbin Gateway Agent Permissions", + description: "Refuses a tool call the agent's Casbin Gateway permissions do not allow", + configSchema: { + safeParse: (value) => ({ success: true, data: value }), + jsonSchema: { type: "object", additionalProperties: true }, + }, + register(api) { + // block is terminal in OpenClaw: once a handler sets it, the call is + // refused and blockReason is what the model is told. + api.on("before_tool_call", async (event, ctx) => { + const reason = await decide( + event?.toolName || ctx?.toolName, + ctx?.sessionKey || event?.sessionKey, + event?.toolCallId || ctx?.toolCallId, + ); + return reason ? { block: true, blockReason: reason } : {}; + }); + }, +}; --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
