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]
