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 1c61862d731f73bb34436584f99ebae30914ac4c
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 22:43:30 2026 +0800

    feat(permissions): extend hook enforcement to hermes-agent and windsurf
---
 agenthook/decide.go           | 59 +++++++++++++++++++++++++++++++++++--------
 agenthook/hook.go             | 33 +++++++++++++-----------
 agentpatch/hermes_agent.go    | 14 ++++++++--
 agentpatch/hermes_observer.py | 56 ++++++++++++++++++++++++++++++++++++++--
 agentpatch/hermes_plugin.yaml |  4 +--
 agentpatch/windsurf.go        |  8 ++++--
 6 files changed, 140 insertions(+), 34 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
index b86433d..53c314f 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -22,6 +22,7 @@ package agenthook
 import (
        "bytes"
        "encoding/json"
+       "fmt"
        "io"
        "net/http"
        "time"
@@ -49,34 +50,61 @@ var preToolEvents = map[string][]string{
        // Cursor asks separately for a shell command and an MCP call, and 
neither
        // carries a tool name the general event would have given.
        "cursor": {"preToolUse", "beforeShellExecution", "beforeMCPExecution"},
+       // Cascade names an action rather than a tool, and asks about each kind 
of
+       // action separately.
+       "windsurf": {"pre_run_command", "pre_write_code", "pre_mcp_tool_use"},
+}
+
+// eventName is what the agent calls the event, read from the field it puts it
+// in. Cascade is the one that names it something else.
+func eventName(agentID string, event map[string]any) string {
+       if agentID == "windsurf" {
+               return stringValue(event["agent_action_name"])
+       }
+       return stringValue(event["hook_event_name"])
 }
 
 // preToolEvent reports whether this event is one the agent is waiting on, and
 // the tool it is about, named the way the permissions catalogue names tools.
 func preToolEvent(agentID string, event map[string]any) (string, bool) {
-       name := stringValue(event["hook_event_name"])
+       name := eventName(agentID, event)
        if name == "" || !listed(preToolEvents[agentID], name) {
                return "", false
        }
 
        switch name {
-       case "beforeShellExecution":
+       case "beforeShellExecution", "pre_run_command":
                // Only the command is carried, and running one is the whole of 
what
                // this event is.
                return "shell", true
+       case "pre_write_code":
+               // Cascade edits a file in place, so this is the edit switch 
rather than
+               // the one for creating a file.
+               return "edit_file", true
        case "beforeMCPExecution":
-               server := stringValue(event["mcp_server_name"])
-               tool := stringValue(event["tool_name"])
-               if server == "" {
-                       return tool, tool != ""
+               return mcpToolName(stringValue(event["mcp_server_name"]), 
stringValue(event["tool_name"]))
+       case "pre_mcp_tool_use":
+               info, _ := event["tool_info"].(map[string]any)
+               if info == nil {
+                       info = map[string]any{}
                }
-               return "mcp__" + server + "__" + tool, true
+               return mcpToolName(stringValue(info["mcp_server_name"]), 
stringValue(info["mcp_tool_name"]))
        }
 
        tool := stringValue(event["tool_name"])
        return tool, tool != ""
 }
 
+// mcpToolName is how an MCP call is named where the agent reports the server
+// and the tool apart: the permissions catalogue reads the server out of the
+// combined name.
+func mcpToolName(server string, tool string) (string, bool) {
+       if server == "" {
+               return tool, tool != ""
+       }
+       return "mcp__" + server + "__" + tool, true
+}
+
 func listed(values []string, name string) bool {
        for _, value := range values {
                if value == name {
@@ -125,10 +153,14 @@ func allowed(url string, token string, request 
map[string]any) (bool, string) {
        return false, answer.Data.Reason
 }
 
-// writeDenial tells the agent to refuse the call, in the shape it reads. The
-// three schemas below are the agents' own, and an agent Gateway has no schema
+// blockedExitCode is what Cascade reads a refusal as: it takes no answer on
+// stdout, and blocks the action on this code alone.
+const blockedExitCode = 2
+
+// writeDenial tells the agent to refuse the call, in the shape it reads, and
+// answers with the exit code that goes with it. An agent Gateway has no shape
 // for is left alone: a hook that only observes must print nothing at all.
-func writeDenial(out io.Writer, agentID string, reason string) {
+func writeDenial(out io.Writer, errOut io.Writer, agentID string, reason 
string) int {
        var answer map[string]any
        switch agentID {
        case "claude-code", "qwen-code":
@@ -143,8 +175,13 @@ func writeDenial(out io.Writer, agentID string, reason 
string) {
                answer = map[string]any{"decision": "deny", "reason": reason}
        case "cursor":
                answer = map[string]any{"permission": "deny", "user_message": 
reason, "agent_message": reason}
+       case "windsurf":
+               // Cascade shows what the hook wrote on stderr and blocks on 
the code.
+               _, _ = fmt.Fprintln(errOut, reason)
+               return blockedExitCode
        default:
-               return
+               return 0
        }
        _ = json.NewEncoder(out).Encode(answer)
+       return 0
 }
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 0950340..1a88142 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -52,13 +52,14 @@ func ServeIfInvoked() {
        }
        timer := time.AfterFunc(hookLifetime, terminateSelf)
        defer timer.Stop()
-       _ = Run(os.Args[2:], os.Stdin, os.Stdout)
-       os.Exit(0)
+       code, _ := Run(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)
+       os.Exit(code)
 }
 
 // Run reads one hook event, decides it where the agent is waiting on a 
verdict,
-// and reports its normalized record.
-func Run(args []string, input io.Reader, output io.Writer) error {
+// and reports its normalized record. The exit code it answers with is the one
+// the agent reads: some take a refusal on stdout, Cascade takes it as a code.
+func Run(args []string, input io.Reader, output io.Writer, errOutput 
io.Writer) (int, error) {
        flags := flag.NewFlagSet(Subcommand, flag.ContinueOnError)
        flags.SetOutput(io.Discard)
        managed := flags.Bool("casbin-gateway-agent-monitor", false, 
"Gateway-managed monitor hook")
@@ -69,47 +70,49 @@ func Run(args []string, input io.Reader, output io.Writer) 
error {
        ingestToken := flags.String("ingest-token", "", "credential presented 
to the Gateway record endpoint")
        decisionURL := flags.String("decision-url", "", "Gateway endpoint that 
decides a tool call")
        if err := flags.Parse(args); err != nil {
-               return err
+               return 0, err
        }
        normalize, known := normalizers[*agentID]
        if !*managed || !known {
-               return fmt.Errorf("unsupported hook agent %q", *agentID)
+               return 0, fmt.Errorf("unsupported hook agent %q", *agentID)
        }
 
        decoder := json.NewDecoder(io.LimitReader(input, maxHookInput))
        decoder.UseNumber()
        var event map[string]any
        if err := decoder.Decode(&event); err != nil {
-               return err
+               return 0, err
        }
        // The verdict comes before the record: the agent is held up until this 
hook
-       // answers, and the report is best effort either way.
+       // answers, and the report is best effort either way, so nothing below 
may
+       // lose the code the verdict decided.
+       code := 0
        if tool, ok := preToolEvent(*agentID, event); ok {
                request := map[string]any{
                        "agent":      *agentID,
                        "tool":       tool,
-                       "sessionKey": firstString(event, "session_id", 
"conversation_id"),
+                       "sessionKey": firstString(event, "session_id", 
"conversation_id", "trajectory_id"),
                        "toolUseId":  stringValue(event["tool_use_id"]),
                }
                if allow, reason := allowed(*decisionURL, *ingestToken, 
request); !allow {
-                       writeDenial(output, *agentID, reason)
+                       code = writeDenial(output, errOutput, *agentID, reason)
                }
        }
 
        record := normalize(event, *agentPath, time.Now())
        if record == nil || *recordsURL == "" {
-               return nil
+               return code, nil
        }
        if *owner != "" {
                record.User = *owner
        }
        body, err := json.Marshal(record)
        if err != nil {
-               return err
+               return code, err
        }
        request, err := http.NewRequest(http.MethodPost, *recordsURL, 
bytes.NewReader(body))
        if err != nil {
-               return err
+               return code, err
        }
        request.Header.Set("Content-Type", "application/json")
        if *ingestToken != "" {
@@ -117,9 +120,9 @@ func Run(args []string, input io.Reader, output io.Writer) 
error {
        }
        response, err := (&http.Client{Timeout: reportTimeout}).Do(request)
        if err != nil {
-               return err
+               return code, err
        }
-       return response.Body.Close()
+       return code, response.Body.Close()
 }
 
 // normalizers maps each agent that reports through a command hook to the
diff --git a/agentpatch/hermes_agent.go b/agentpatch/hermes_agent.go
index 3f3704a..1007876 100644
--- a/agentpatch/hermes_agent.go
+++ b/agentpatch/hermes_agent.go
@@ -70,6 +70,7 @@ type hermesObserverConfig struct {
        Owner             string `json:"owner"`
        PluginVersion     string `json:"pluginVersion"`
        RecordsURL        string `json:"recordsUrl"`
+       DecisionURL       string `json:"decisionUrl"`
        AgentPath         string `json:"agentPath"`
        User              string `json:"user"`
        IngestToken       string `json:"ingestToken"`
@@ -157,12 +158,16 @@ func (p hermesPatcher) Unpatch(target Target) error {
 
 // PatchNotice is the copy the agents table shows before and after the button,
 // so the Web UI does not need a branch of its own for how Hermes loads 
plugins.
+// Decides: the plugin's pre_tool_call callback returns a block directive, 
which
+// Hermes honours by refusing the call and handing the message to the model.
+func (hermesPatcher) Decides() bool { return true }
+
 func (hermesPatcher) PatchNotice(patched bool) (string, string) {
        if patched {
-               return "Removes the behaviour observer from the default Hermes 
profile and drops its entry from plugins.enabled. Nothing else in config.yaml 
changes.",
+               return "Removes the behaviour observer from the default Hermes 
profile and drops its entry from plugins.enabled, and with it the check before 
each tool call. Nothing else in config.yaml changes.",
                        "Restart running Hermes processes to unload it."
        }
-       return "Installs a behaviour observer with redacted tool inputs in the 
default Hermes profile and adds it to plugins.enabled. Named profiles are left 
alone.",
+       return "Installs a behaviour observer with redacted tool inputs in the 
default Hermes profile and adds it to plugins.enabled. It also refuses a tool 
call this agent's permissions do not allow; an agent nobody has restricted is 
never held up. Named profiles are left alone.",
                "Restart running Hermes processes to load it."
 }
 
@@ -264,11 +269,16 @@ func renderHermesObserverConfig(target Target, token 
string) ([]byte, error) {
        if err != nil {
                return nil, err
        }
+       decision, err := decisionURL()
+       if err != nil {
+               return nil, err
+       }
        data, err := json.MarshalIndent(hermesObserverConfig{
                SchemaVersion:     hermesConfigSchema,
                Owner:             "casbin-gateway",
                PluginVersion:     hermesPluginVersion,
                RecordsURL:        url,
+               DecisionURL:       decision,
                AgentPath:         target.Path,
                User:              target.Owner,
                IngestToken:       token,
diff --git a/agentpatch/hermes_observer.py b/agentpatch/hermes_observer.py
index b0032a0..4ff6cf0 100644
--- a/agentpatch/hermes_observer.py
+++ b/agentpatch/hermes_observer.py
@@ -3,6 +3,11 @@
 This module intentionally never serializes prompts, responses, reasoning,
 tool results, or subagent text. Tool arguments are redacted and bounded before
 hook callbacks enqueue records without waiting for the Gateway HTTP endpoint.
+
+One callback is not an observer: pre_tool_call asks Gateway whether the call is
+allowed and returns a block directive when it is not. That question is the only
+thing in here a session waits on, and anything that goes wrong with it allows
+the call.
 """
 
 from __future__ import annotations
@@ -104,6 +109,10 @@ _AGENT_PATH = str(_CONFIG.get("agentPath") or "")
 _USER = str(_CONFIG.get("user") or "")
 _INGEST_TOKEN = str(_CONFIG.get("ingestToken") or "")
 _INGEST_TOKEN_HEADER = str(_CONFIG.get("ingestTokenHeader") or "")
+_DECISION_URL = str(_CONFIG.get("decisionUrl") or "")
+# The tool call waits on this, so a Gateway that is slow to answer is treated 
as
+# one that is not running.
+_DECIDE_TIMEOUT = 2.0
 
 
 def _text(value: Any, limit: int = 512) -> str:
@@ -433,8 +442,50 @@ def _tool_record(action: str, outcome: str, values: 
dict[str, Any]) -> dict[str,
     return {key: value for key, value in record.items() if value not in ("", 
None)}
 
 
-def _on_pre_tool_call(**values: Any) -> None:
+def _decide(tool_name: str, session_id: str, tool_call_id: str) -> str:
+    """Ask Gateway whether a tool call may go ahead, answering with the reason
+    it may not. Every failure answers "" - a plugin that cannot get a verdict
+    must not stop Hermes working."""
+    if not _DECISION_URL or not tool_name:
+        return ""
+    try:
+        body = json.dumps(
+            {
+                "agent": "hermes-agent",
+                "tool": tool_name,
+                "sessionKey": session_id,
+                "toolUseId": tool_call_id,
+            }
+        ).encode("utf-8")
+        request = urllib.request.Request(
+            _DECISION_URL, data=body, headers={"Content-Type": 
"application/json"}
+        )
+        if _INGEST_TOKEN and _INGEST_TOKEN_HEADER:
+            request.add_header(_INGEST_TOKEN_HEADER, _INGEST_TOKEN)
+        with urllib.request.urlopen(request, timeout=_DECIDE_TIMEOUT) as 
response:
+            answer = json.loads(response.read().decode("utf-8"))
+    except Exception:
+        return ""
+
+    if not isinstance(answer, dict) or answer.get("status") != "ok":
+        return ""
+    data = answer.get("data")
+    if not isinstance(data, dict) or data.get("allow") is not False:
+        return ""
+    return str(data.get("reason") or f"the permissions of this agent do not 
allow {tool_name}")
+
+
+def _on_pre_tool_call(**values: Any) -> dict[str, Any] | None:
     _enqueue(_tool_record("call", "attempted", values))
+    refusal = _decide(
+        _text(values.get("tool_name")),
+        _text(values.get("session_id")),
+        _text(values.get("tool_call_id")),
+    )
+    # Hermes vetoes the call on this directive and hands the message to the
+    # model; any other return value is ignored, which is what leaves every
+    # other callback here an observer.
+    return {"action": "block", "message": refusal} if refusal else None
 
 
 def _on_post_tool_call(**values: Any) -> None:
@@ -528,7 +579,8 @@ _HOOKS = {
 
 
 def register(ctx: Any) -> None:
-    """Register observational hooks. Return values are deliberately ignored."""
+    """Register the hooks. Every return value is ignored but pre_tool_call's,
+    which is the one Hermes reads a block directive from."""
     _start_sender()
     for name, callback in _HOOKS.items():
         ctx.register_hook(name, callback)
diff --git a/agentpatch/hermes_plugin.yaml b/agentpatch/hermes_plugin.yaml
index 83951cc..d830ac3 100644
--- a/agentpatch/hermes_plugin.yaml
+++ b/agentpatch/hermes_plugin.yaml
@@ -1,6 +1,6 @@
 name: casbin-gateway-agent-monitor
-version: "1.1.0"
-description: "Behaviour audit with redacted tool inputs for Casbin Gateway."
+version: "1.2.0"
+description: "Behaviour audit with redacted tool inputs, and a permission 
check before each tool call, for Casbin Gateway."
 author: Casbin
 kind: standalone
 # "provides_hooks" is the key Hermes' manifest parser reads; the callbacks
diff --git a/agentpatch/windsurf.go b/agentpatch/windsurf.go
index 478d89d..9c008be 100644
--- a/agentpatch/windsurf.go
+++ b/agentpatch/windsurf.go
@@ -110,11 +110,15 @@ func (p windsurfPatcher) Status(target Target) (Status, 
error) {
        return Status{Patched: true, Detail: "Cascade hooks active"}, nil
 }
 
+// Decides: Cascade waits on its pre_ hooks and blocks the action when one 
exits
+// with code 2, which is how a refusal is written there.
+func (windsurfPatcher) Decides() bool { return true }
+
 func (windsurfPatcher) PatchNotice(patched bool) (string, string) {
        if patched {
-               return "Removes Gateway's audit-only Cascade hooks.", "Restart 
Windsurf to stop running them."
+               return "Removes Gateway's Cascade hooks, and with them the 
check before a command, an edit or an MCP call.", "Restart Windsurf to stop 
running them."
        }
-       return "Installs audit-only Cascade hooks. They observe events and 
never block an action.",
+       return "Installs Gateway's Cascade hooks. They observe events, and 
refuse a command, an edit or an MCP call this agent's permissions do not allow; 
an agent nobody has restricted is never held up.",
                "Restart Windsurf to load them."
 }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to