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]
