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


The following commit(s) were added to refs/heads/master by this push:
     new 6341e30  feat(agents): add Grok Build hooks for monitoring and 
tool-call checks
6341e30 is described below

commit 6341e303abb9d1729e43acdb0d377ae148d3381f
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 27 22:36:05 2026 +0800

    feat(agents): add Grok Build hooks for monitoring and tool-call checks
---
 agenthook/decide.go        |   6 +-
 agenthook/grok.go          | 150 +++++++++++++++++++++++++++++++++++
 agenthook/hook.go          |  11 +++
 agentpatch/grok.go         | 189 +++++++++++++++++++++++++++++++++++++++++++++
 object/permission_tools.go |  12 +--
 5 files changed, 361 insertions(+), 7 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
index 855a2a9..140cacc 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -47,6 +47,7 @@ var preToolEvents = map[string][]string{
        "claude-code": {"PreToolUse"},
        "qwen-code":   {"PreToolUse"},
        "gemini-cli":  {"BeforeTool"},
+       "grok-build":  {"PreToolUse"},
        // Cursor asks separately for a shell command, an MCP call, a file read 
and
        // a subagent, and none of those carries a tool name the general event 
would
        // have given.
@@ -102,6 +103,9 @@ func preToolEvent(agentID string, event map[string]any) 
(string, bool) {
        }
 
        tool := stringValue(event["tool_name"])
+       if agentID == "grok-build" {
+               tool = grokToolName(event)
+       }
        return tool, tool != ""
 }
 
@@ -221,7 +225,7 @@ func writeDenial(out io.Writer, errOut io.Writer, agentID 
string, reason string)
                                "permissionDecisionReason": reason,
                        },
                }
-       case "gemini-cli":
+       case "gemini-cli", "grok-build":
                answer = map[string]any{"decision": "deny", "reason": reason}
        case "cursor":
                answer = map[string]any{"permission": "deny", "user_message": 
reason, "agent_message": reason}
diff --git a/agenthook/grok.go b/agenthook/grok.go
new file mode 100644
index 0000000..57a2dfc
--- /dev/null
+++ b/agenthook/grok.go
@@ -0,0 +1,150 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agenthook
+
+import (
+       "os"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/auditutil"
+)
+
+// grokHookEnv is set by Grok's hook runner on every hook it starts, and on
+// nothing else.
+const grokHookEnv = "GROK_HOOK_EVENT"
+
+// GrokEvents are the hook events Gateway installs for Grok Build, in the
+// Claude Code names its hook files take.
+var GrokEvents = []string{
+       "SessionStart", "SessionEnd", "UserPromptSubmit", "Stop", "StopFailure",
+       "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionDenied",
+       "SubagentStart", "SubagentStop", "Notification", "PreCompact", 
"PostCompact",
+}
+
+// InsideGrok reports that Grok started this process as a hook. Grok also runs
+// the hooks Gateway installs for Claude Code and Cursor, and those must leave
+// a Grok call to the hook Grok has of its own.
+func InsideGrok() bool {
+       return os.Getenv(grokHookEnv) != ""
+}
+
+// NormalizeGrok maps Grok Build's hook event schema to Gateway's agent
+// monitoring record. Grok sends each field twice, camelCase and Claude Code's
+// snake_case, and the latter is read here.
+func NormalizeGrok(event map[string]any, agentPath string, now time.Time) 
*agentmonitor.Record {
+       record := &agentmonitor.Record{
+               Agent:       "grok-build",
+               AgentPath:   agentPath,
+               CreatedTime: now.Format(time.RFC3339Nano),
+               SessionKey:  stringValue(event["session_id"]),
+               PromptId:    stringValue(event["promptId"]),
+               ToolUseId:   stringValue(event["tool_use_id"]),
+               ToolName:    grokToolName(event),
+               DurationMs:  int64Value(event["duration_ms"]),
+       }
+
+       switch stringValue(event["hook_event_name"]) {
+       case "SessionStart":
+               record.EventType, record.Action = "session", "start"
+       case "SessionEnd":
+               record.EventType, record.Action = "session", "end"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["reason"]))
+       case "Stop":
+               // Grok fires one more Stop as the session closes, which 
SessionEnd
+               // already records.
+               if reason := stringValue(event["reason"]); reason != "" && 
reason != "end_turn" {
+                       return nil
+               }
+               record.EventType, record.Action, record.Outcome = "session", 
"stop", "success"
+       case "StopFailure":
+               record.EventType, record.Action, record.Outcome = "session", 
"stop", "failure"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["error"]))
+       case "UserPromptSubmit":
+               record.EventType, record.Action, record.Outcome = "prompt", 
"submitted", "attempted"
+               record.Title = 
auditutil.SanitizeString(stringValue(event["prompt"]))
+       case "PreToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "attempted"
+       case "PostToolUse":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "success"
+       case "PostToolUseFailure":
+               record.EventType, record.Action, record.Outcome = "tool", 
"call", "failure"
+               record.Detail = auditutil.SanitizeString(firstString(event, 
"error", "errorDetails"))
+       case "PermissionDenied":
+               record.EventType, record.Action, record.Outcome = "permission", 
"denied", "denied"
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["reason"]))
+       case "SubagentStart":
+               record.EventType, record.Action = "subagent", "start"
+       case "SubagentStop":
+               record.EventType, record.Action, record.Outcome = "subagent", 
"stop", "success"
+       case "Notification":
+               record.EventType, record.Action = "notification", 
firstString(event, "notification_type", "notificationType")
+               record.Detail = 
auditutil.SanitizeString(stringValue(event["message"]))
+       case "PreCompact":
+               record.EventType, record.Action, record.Outcome = "compact", 
"before", "attempted"
+       case "PostCompact":
+               record.EventType, record.Action, record.Outcome = "compact", 
"after", "success"
+       default:
+               return nil
+       }
+
+       if record.Action == "" {
+               record.Action = "observed"
+       }
+       if server, tool, ok := auditutil.ParseMcpTool(record.ToolName, 
"mcp__"); ok {
+               record.McpServer, record.McpTool = server, tool
+               if record.EventType == "tool" {
+                       record.EventType = "mcp"
+               }
+       }
+       record.Object = auditutil.EncodeBoundedJSON(grokPayload(event, 
record.ToolName), auditutil.MaxPayloadBytes)
+       return record
+}
+
+// grokToolName names an MCP call the way the permissions catalogue does. Grok
+// calls one "<server>__<tool>", or dispatches it through use_tool.
+func grokToolName(event map[string]any) string {
+       name := stringValue(event["tool_name"])
+       if name == "use_tool" {
+               if input, ok := event["tool_input"].(map[string]any); ok && 
stringValue(input["tool_name"]) != "" {
+                       name = stringValue(input["tool_name"])
+               }
+       }
+       if strings.Contains(name, "__") && !strings.HasPrefix(name, "mcp__") {
+               return "mcp__" + name
+       }
+       return name
+}
+
+// grokPayload keeps one copy of each field, and drops what a record cannot
+// carry: paths into Grok's own storage and the content a tool returned.
+func grokPayload(event map[string]any, toolName string) map[string]any {
+       payload := make(map[string]any, len(event))
+       for key, value := range event {
+               switch key {
+               case "transcript_path", "transcriptPath", "tool_response", 
"toolResult", "toolInput",
+                       "hookEventName", "sessionId", "permissionMode", 
"toolName", "toolUseId", "durationMs":
+                       continue
+               case "lastAssistantMessage", "compactSummary":
+                       payload[key+"Length"] = len(stringValue(value))
+               case "tool_input":
+                       payload[key] = auditutil.SanitizeToolInput(toolName, 
value)
+               default:
+                       payload[key] = auditutil.SanitizeValue(key, value)
+               }
+       }
+       return payload
+}
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 0b47cb7..f447587 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -47,6 +47,11 @@ const (
 // its normal HTTP server. Hook delivery is best effort and must never affect a
 // Claude Code action, so errors are intentionally ignored here.
 func ServeIfInvoked() {
+       // Grok runs Claude Code's hooks without their args array, which would
+       // otherwise start a whole second Gateway from inside a Grok session.
+       if len(os.Args) < 2 && InsideGrok() {
+               os.Exit(0)
+       }
        if len(os.Args) < 2 || os.Args[1] != Subcommand {
                return
        }
@@ -76,6 +81,11 @@ func Run(args []string, input io.Reader, output io.Writer, 
errOutput io.Writer)
        if !*managed || !known {
                return 0, fmt.Errorf("unsupported hook agent %q", *agentID)
        }
+       // A Claude Code or Cursor hook that Grok ran is a Grok call, which 
Grok's
+       // own hook records and decides.
+       if *agentID != "grok-build" && InsideGrok() {
+               return 0, nil
+       }
 
        decoder := json.NewDecoder(io.LimitReader(input, maxHookInput))
        decoder.UseNumber()
@@ -134,6 +144,7 @@ var normalizers = map[string]func(map[string]any, string, 
time.Time) *agentmonit
        "claude-code": Normalize,
        "cursor":      NormalizeCursor,
        "gemini-cli":  NormalizeGemini,
+       "grok-build":  NormalizeGrok,
        "qwen-code":   NormalizeQwen,
        "windsurf":    NormalizeWindsurf,
 }
diff --git a/agentpatch/grok.go b/agentpatch/grok.go
new file mode 100644
index 0000000..52e0095
--- /dev/null
+++ b/agentpatch/grok.go
@@ -0,0 +1,189 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentpatch
+
+import (
+       "errors"
+       "os"
+       "os/user"
+       "path/filepath"
+       "runtime"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agenthook"
+)
+
+// grokHookFile is Gateway's own file among Grok's global hooks. Grok loads
+// every JSON file in $GROK_HOME/hooks as trusted, so the hand-edited
+// config.toml is never touched and unpatching is deleting this file.
+const grokHookFile = "casbin-gateway.json"
+
+// grokHookTimeout is in seconds. Grok gives Stop and PostToolUse ten minutes
+// by default, which a hook that only reports should never hold a turn for.
+const grokHookTimeout = 5
+
+type grokPatcher struct{}
+
+func init() {
+       register(grokPatcher{})
+}
+
+func (grokPatcher) AgentId() string { return "grok-build" }
+
+func (grokPatcher) Supported() bool { return true }
+
+// Decides: Grok waits on PreToolUse and refuses the call on a deny decision.
+func (grokPatcher) Decides() bool { return true }
+
+func (p grokPatcher) Patch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := grokHookPath(target.Owner)
+       if err != nil {
+               return err
+       }
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return err
+       }
+       args, err := hookArgs(p.AgentId(), target)
+       if err != nil {
+               return err
+       }
+       command := grokCommandLine(executable, args)
+
+       hooks := map[string]any{}
+       for _, event := range agenthook.GrokEvents {
+               hooks[event] = []any{map[string]any{"hooks": 
[]any{map[string]any{
+                       "type":    "command",
+                       "command": command,
+                       "timeout": grokHookTimeout,
+               }}}}
+       }
+       return writeJSONConfig(path, map[string]any{"hooks": hooks}, 0o600)
+}
+
+func (p grokPatcher) Unpatch(target Target) error {
+       stateMutex.Lock()
+       defer stateMutex.Unlock()
+
+       path, err := grokHookPath(target.Owner)
+       if err != nil {
+               return err
+       }
+       if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+               return err
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p grokPatcher) Status(target Target) (Status, error) {
+       path, err := grokHookPath(target.Owner)
+       if err != nil {
+               return Status{}, err
+       }
+       config, _, exists, err := readJSONConfig(path)
+       if err != nil {
+               return Status{}, err
+       }
+       hooks, ok := objectAt(config["hooks"])
+       if !exists || !ok {
+               return Status{Detail: "Grok Build hooks are not installed"}, nil
+       }
+       for _, event := range agenthook.GrokEvents {
+               if !hasHook(hooks[event], grokHookIsCurrent) {
+                       return Status{Detail: "Grok Build hooks need refresh"}, 
nil
+               }
+       }
+       return Status{Patched: true, Detail: "Grok Build hooks active"}, nil
+}
+
+func (grokPatcher) PatchNotice(patched bool) (string, string) {
+       restart := "Restart any Grok Build session that is already running."
+       if patched {
+               return "Removes Gateway's Grok Build hooks, and with them the 
check before each tool call.", restart
+       }
+       return "Installs Gateway's Grok Build 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 grokHookIsCurrent(handler map[string]any) bool {
+       command, _ := handler["command"].(string)
+       if !strings.Contains(command, agenthook.OwnershipFlag) || 
!strings.Contains(command, "grok-build") {
+               return false
+       }
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return false
+       }
+       url, err := recordsURL()
+       if err != nil {
+               return false
+       }
+       decision, err := decisionURL()
+       if err != nil {
+               return false
+       }
+       return strings.HasPrefix(command, grokCommandLine(executable, nil)) &&
+               strings.Contains(command, url) && strings.Contains(command, 
decision)
+}
+
+// grokCommandLine is one command line for Grok's shell. Grok ignores an args
+// array and hands the command to PowerShell on Windows, where a quoted program
+// needs the call operator and an empty argument is dropped outright.
+func grokCommandLine(executable string, args []string) string {
+       if runtime.GOOS != "windows" {
+               return hookCommandLine(executable, args)
+       }
+       parts := []string{"&", powershellQuote(executable)}
+       for index := 0; index < len(args); index++ {
+               if strings.HasPrefix(args[index], "--") && index+1 < len(args) 
&& args[index+1] == "" {
+                       parts = append(parts, args[index]+"=")
+                       index++
+                       continue
+               }
+               parts = append(parts, powershellQuote(args[index]))
+       }
+       return strings.Join(parts, " ")
+}
+
+func powershellQuote(value string) string {
+       for _, char := range value {
+               if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' 
|| char >= '0' && char <= '9' ||
+                       strings.ContainsRune(`-_./:=\`, char)) {
+                       return "'" + strings.ReplaceAll(value, "'", "''") + "'"
+               }
+       }
+       return value
+}
+
+// grokHookPath is where Grok looks for global hooks. GROK_HOME moves it, but
+// only for the account Gateway runs as.
+func grokHookPath(owner string) (string, error) {
+       if current, err := user.Current(); err == nil && 
agenthome.SameAccount(owner, current.Username) {
+               if configured := strings.TrimSpace(os.Getenv("GROK_HOME")); 
configured != "" {
+                       if !filepath.IsAbs(configured) {
+                               return "", errors.New("GROK_HOME must be an 
absolute path")
+                       }
+                       return filepath.Join(filepath.Clean(configured), 
"hooks", grokHookFile), nil
+               }
+       }
+       home, err := agenthome.Resolve(owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".grok", "hooks", grokHookFile), nil
+}
diff --git a/object/permission_tools.go b/object/permission_tools.go
index 529aec5..2864d78 100644
--- a/object/permission_tools.go
+++ b/object/permission_tools.go
@@ -69,9 +69,9 @@ func item(group string, name string, tools ...string) 
ToolItem {
 // toolCatalog is every switch there is. The order is the order they are drawn
 // in, and the "other" item of a group always comes last.
 var toolCatalog = []ToolItem{
-       item(GroupShell, "run", "Bash", "shell", "run_shell_command", 
"run_terminal_cmd", "execute_command"),
-       item(GroupShell, "output", "BashOutput"),
-       item(GroupShell, "kill", "KillShell", "KillBash"),
+       item(GroupShell, "run", "Bash", "shell", "run_shell_command", 
"run_terminal_cmd", "run_terminal_command", "execute_command"),
+       item(GroupShell, "output", "BashOutput", 
"get_command_or_subagent_output"),
+       item(GroupShell, "kill", "KillShell", "KillBash", 
"kill_command_or_subagent"),
        item(GroupShell, otherItem),
 
        item(GroupRead, "file", "Read", "read_file", "view"),
@@ -85,7 +85,7 @@ var toolCatalog = []ToolItem{
        item(GroupRead, otherItem),
 
        item(GroupWrite, "create", "Write", "write_file", "create_file"),
-       item(GroupWrite, "edit", "Edit", "edit_file", "replace", 
"str_replace_editor"),
+       item(GroupWrite, "edit", "Edit", "edit_file", "replace", 
"str_replace_editor", "search_replace"),
        item(GroupWrite, "multi", "MultiEdit"),
        item(GroupWrite, "patch", "apply_patch"),
        item(GroupWrite, "notebook", "NotebookEdit"),
@@ -99,9 +99,9 @@ var toolCatalog = []ToolItem{
        item(GroupNetwork, "browser", "browser_navigate", "browser_click", 
"playwright"),
        item(GroupNetwork, otherItem),
 
-       item(GroupAgentic, "subagent", "Task", "Agent"),
+       item(GroupAgentic, "subagent", "Task", "Agent", "spawn_subagent"),
        item(GroupAgentic, "todo", "TodoWrite", "TodoRead", "update_plan"),
-       item(GroupAgentic, "plan", "ExitPlanMode"),
+       item(GroupAgentic, "plan", "ExitPlanMode", "enter_plan_mode"),
        item(GroupAgentic, "ask", "AskUserQuestion"),
        item(GroupAgentic, "command", "SlashCommand"),
        item(GroupAgentic, "skill", "Skill"),


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

Reply via email to