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]