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