You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/04 14:43:39 UTC

(casbin-gateway) 01/04: feat(permissions): extend hook enforcement to cursor, gemini-cli and qwen-code

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 <hs...@gmail.com>
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: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org