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 2ab0fdf52e2a4d5b7ae0c91da978b8c56ee82d6a
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 22:26:14 2026 +0800

    feat(permissions): extend hook enforcement to cursor, gemini-cli and 
qwen-code
---
 agenthook/decide.go          | 81 +++++++++++++++++++++++++++++++++-----------
 agenthook/hook.go            |  2 +-
 agentpatch/cursor.go         |  8 +++--
 agentpatch/hook_groups.go    | 15 +++++++-
 agentpatch/settings_hooks.go | 11 ++++--
 5 files changed, 90 insertions(+), 27 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
index 4c4d4e6..b86433d 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// This file is the blocking half of the hook. A pre-tool event asks Gateway
-// whether the call may go ahead, and an agent that speaks a refusal writes it
-// on stdout in its own words. Everything else about the hook stays an
+// This file is the blocking half of the hook. The event an agent fires before
+// it runs a tool asks Gateway whether the call may go ahead, and a refusal is
+// written on stdout in that agent's own words. Every other event stays an
 // observation.
 
 package agenthook
@@ -40,19 +40,52 @@ type decision struct {
        Reason string `json:"reason"`
 }
 
-// preToolEvent reports whether this event is the one asked before a tool runs,
-// by the name each agent gives it, and the tool it is about.
+// preToolEvents are the events each agent fires before running a tool, and can
+// be told to refuse. An agent absent from here only observes.
+var preToolEvents = map[string][]string{
+       "claude-code": {"PreToolUse"},
+       "qwen-code":   {"PreToolUse"},
+       "gemini-cli":  {"BeforeTool"},
+       // 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"},
+}
+
+// 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) {
-       if agentID != "claude-code" {
+       name := stringValue(event["hook_event_name"])
+       if name == "" || !listed(preToolEvents[agentID], name) {
                return "", false
        }
-       if name, _ := event["hook_event_name"].(string); name != "PreToolUse" {
-               return "", false
+
+       switch name {
+       case "beforeShellExecution":
+               // Only the command is carried, and running one is the whole of 
what
+               // this event is.
+               return "shell", true
+       case "beforeMCPExecution":
+               server := stringValue(event["mcp_server_name"])
+               tool := stringValue(event["tool_name"])
+               if server == "" {
+                       return tool, tool != ""
+               }
+               return "mcp__" + server + "__" + tool, true
        }
-       tool, _ := event["tool_name"].(string)
+
+       tool := stringValue(event["tool_name"])
        return tool, tool != ""
 }
 
+func listed(values []string, name string) bool {
+       for _, value := range values {
+               if value == name {
+                       return true
+               }
+       }
+       return false
+}
+
 // allowed asks Gateway whether this tool call may go ahead. Anything that goes
 // wrong - no endpoint, no answer, a body that will not parse - allows the 
call:
 // a hook that cannot get a verdict must not stop the agent from working.
@@ -92,18 +125,26 @@ 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. 
Claude
-// Code takes a permission decision off the hook's stdout; an agent with no 
such
-// shape is left alone, since a hook that only observes must print nothing.
+// 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
+// for is left alone: a hook that only observes must print nothing at all.
 func writeDenial(out io.Writer, agentID string, reason string) {
-       if agentID != "claude-code" {
+       var answer map[string]any
+       switch agentID {
+       case "claude-code", "qwen-code":
+               answer = map[string]any{
+                       "hookSpecificOutput": map[string]any{
+                               "hookEventName":            "PreToolUse",
+                               "permissionDecision":       "deny",
+                               "permissionDecisionReason": reason,
+                       },
+               }
+       case "gemini-cli":
+               answer = map[string]any{"decision": "deny", "reason": reason}
+       case "cursor":
+               answer = map[string]any{"permission": "deny", "user_message": 
reason, "agent_message": reason}
+       default:
                return
        }
-       _ = json.NewEncoder(out).Encode(map[string]any{
-               "hookSpecificOutput": map[string]any{
-                       "hookEventName":            "PreToolUse",
-                       "permissionDecision":       "deny",
-                       "permissionDecisionReason": reason,
-               },
-       })
+       _ = json.NewEncoder(out).Encode(answer)
 }
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 112d82b..5586984 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -88,7 +88,7 @@ func Run(args []string, input io.Reader, output io.Writer) 
error {
                request := map[string]any{
                        "agent":      *agentID,
                        "tool":       tool,
-                       "sessionKey": stringValue(event["session_id"]),
+                       "sessionKey": firstString(event, "session_id", 
"conversation_id"),
                        "toolUseId":  stringValue(event["tool_use_id"]),
                }
                if allow, reason := allowed(*decisionURL, *ingestToken, 
request); !allow {
diff --git a/agentpatch/cursor.go b/agentpatch/cursor.go
index 991d41c..c77fc00 100644
--- a/agentpatch/cursor.go
+++ b/agentpatch/cursor.go
@@ -48,6 +48,10 @@ func (p cursorPatcher) AgentId() string { return p.id }
 
 func (cursorPatcher) Supported() bool { return true }
 
+// Decides: Cursor waits on preToolUse, beforeShellExecution and
+// beforeMCPExecution, and a hook that answers "deny" stops the call.
+func (cursorPatcher) Decides() bool { return true }
+
 func (p cursorPatcher) Patch(target Target) error {
        stateMutex.Lock()
        defer stateMutex.Unlock()
@@ -132,10 +136,10 @@ func (p cursorPatcher) Status(target Target) (Status, 
error) {
 
 func (cursorPatcher) PatchNotice(patched bool) (string, string) {
        if patched {
-               return "Removes Gateway's audit-only Cursor hooks. The editor 
and the CLI share them, so both stop reporting.",
+               return "Removes Gateway's Cursor hooks. The editor and the CLI 
share them, so both stop reporting and stop being checked.",
                        "Restart any Cursor session that is already running."
        }
-       return "Installs audit-only Cursor hooks. They observe events and never 
block an action; the CLI reports fewer of them than the editor.",
+       return "Installs Gateway's Cursor hooks. They observe events, and 
refuse a shell command, an MCP call or a tool call this agent's permissions do 
not allow; the CLI reports fewer events than the editor, and an agent nobody 
has restricted is never held up.",
                "Restart any Cursor session that is already running."
 }
 
diff --git a/agentpatch/hook_groups.go b/agentpatch/hook_groups.go
index ba1de52..c171959 100644
--- a/agentpatch/hook_groups.go
+++ b/agentpatch/hook_groups.go
@@ -135,11 +135,16 @@ func hookArgs(agentId string, target Target) ([]string, 
error) {
        if err != nil {
                return nil, err
        }
+       decision, err := decisionURL()
+       if err != nil {
+               return nil, err
+       }
        return []string{
                agenthook.Subcommand,
                agenthook.OwnershipFlag,
                "--agent", agentId,
                "--records-url", url,
+               "--decision-url", decision,
                "--agent-path", target.Path,
                "--user", target.Owner,
                "--ingest-token", token,
@@ -191,7 +196,15 @@ func commandIsCurrent(command string) bool {
        if err != nil {
                return false
        }
-       return strings.Contains(command, quoteArg(executable)) && 
strings.Contains(command, url)
+       // A hook written before Gateway could decide a tool call carries no
+       // decision endpoint, and calling it current would leave it observing 
for
+       // good.
+       decision, err := decisionURL()
+       if err != nil {
+               return false
+       }
+       return strings.Contains(command, quoteArg(executable)) &&
+               strings.Contains(command, url) && strings.Contains(command, 
decision)
 }
 
 // argsAreCurrent is commandIsCurrent for an agent whose configuration keeps 
the
diff --git a/agentpatch/settings_hooks.go b/agentpatch/settings_hooks.go
index 77969b7..85e54b4 100644
--- a/agentpatch/settings_hooks.go
+++ b/agentpatch/settings_hooks.go
@@ -28,7 +28,7 @@ import (
 // hold up the session.
 const hookTimeoutMs = 5000
 
-// settingsHookPatcher installs Gateway's audit-only command hooks into one
+// settingsHookPatcher installs Gateway's command hooks into one
 // agent's settings.json. The Gemini CLI and the forks that took its
 // configuration all spell hooks the same way — events keyed by name, each an
 // array of groups holding command handlers — and differ only in where the file
@@ -46,6 +46,11 @@ func (p settingsHookPatcher) AgentId() string { return 
p.agentId }
 
 func (settingsHookPatcher) Supported() bool { return true }
 
+// Decides: both CLIs written with this patcher wait on their pre-tool event -
+// the Gemini CLI's BeforeTool and Qwen Code's PreToolUse - and honour a 
refusal
+// written on the hook's stdout.
+func (settingsHookPatcher) Decides() bool { return true }
+
 func (p settingsHookPatcher) Patch(target Target) error {
        stateMutex.Lock()
        defer stateMutex.Unlock()
@@ -126,9 +131,9 @@ func (p settingsHookPatcher) Status(target Target) (Status, 
error) {
 func (p settingsHookPatcher) PatchNotice(patched bool) (string, string) {
        restart := "Restart any " + p.name + " session that is already running."
        if patched {
-               return "Removes Gateway's audit-only " + p.name + " hooks.", 
restart
+               return "Removes Gateway's " + p.name + " hooks, and with them 
the check before each tool call.", restart
        }
-       return "Installs audit-only " + p.name + " hooks. Hooks observe events 
and never block an action.", restart
+       return "Installs Gateway's " + p.name + " hooks. They observe events, 
and refuse a tool call this agent's permissions do not allow; an agent nobody 
has restricted is never held up.", restart
 }
 
 func (p settingsHookPatcher) settingsPath(target Target) (string, error) {


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

Reply via email to