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 04c5a71ae83ce0264f1951ea6175f917cf525a91
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 22:09:17 2026 +0800

    feat(permissions): enforce the tool switches at the agent's own hook
---
 agenthook/decide.go                   | 109 ++++++++++++++++++++++++++++++++++
 agenthook/hook.go                     |  22 ++++++-
 agentmonitor/monitor.go               |  20 +++++++
 agentpatch/claude_code.go             |  40 +++++++++++--
 agentpatch/endpoint.go                |  22 +++++--
 agentpatch/opencode.go                |  21 +++++--
 agentpatch/opencode_plugin.js         |  39 +++++++++++-
 agentpatch/patch.go                   |  24 ++++++--
 controllers/agent.go                  |  66 ++++++++++++++++++++
 object/permission_check.go            |  50 ++++++++++++++++
 routers/router.go                     |   1 +
 web/src/components/PermissionCard.tsx |  24 ++++++--
 web/src/lib/agents.ts                 |  20 ++++++-
 web/src/locales/en/data.json          |   3 +-
 web/src/locales/zh/data.json          |   3 +-
 web/src/pages/PermissionsPage.tsx     |   4 +-
 web/src/types.ts                      |   3 +
 17 files changed, 433 insertions(+), 38 deletions(-)

diff --git a/agenthook/decide.go b/agenthook/decide.go
new file mode 100644
index 0000000..4c4d4e6
--- /dev/null
+++ b/agenthook/decide.go
@@ -0,0 +1,109 @@
+// 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.
+
+// 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
+// observation.
+
+package agenthook
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "net/http"
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+)
+
+// decideTimeout bounds the question. The agent is waiting on this hook before
+// it runs the tool, so a Gateway that does not answer quickly is treated as 
one
+// that is not there.
+const decideTimeout = 2 * time.Second
+
+// decision is what Gateway answers.
+type decision struct {
+       Allow  bool   `json:"allow"`
+       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.
+func preToolEvent(agentID string, event map[string]any) (string, bool) {
+       if agentID != "claude-code" {
+               return "", false
+       }
+       if name, _ := event["hook_event_name"].(string); name != "PreToolUse" {
+               return "", false
+       }
+       tool, _ := event["tool_name"].(string)
+       return tool, tool != ""
+}
+
+// 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.
+func allowed(url string, token string, request map[string]any) (bool, string) {
+       if url == "" {
+               return true, ""
+       }
+       body, err := json.Marshal(request)
+       if err != nil {
+               return true, ""
+       }
+       httpRequest, err := http.NewRequest(http.MethodPost, url, 
bytes.NewReader(body))
+       if err != nil {
+               return true, ""
+       }
+       httpRequest.Header.Set("Content-Type", "application/json")
+       if token != "" {
+               httpRequest.Header.Set(agentmonitor.IngestTokenHeader, token)
+       }
+
+       response, err := (&http.Client{Timeout: decideTimeout}).Do(httpRequest)
+       if err != nil {
+               return true, ""
+       }
+       defer response.Body.Close()
+
+       answer := struct {
+               Status string   `json:"status"`
+               Data   decision `json:"data"`
+       }{}
+       if err := json.NewDecoder(io.LimitReader(response.Body, 
maxHookInput)).Decode(&answer); err != nil {
+               return true, ""
+       }
+       if answer.Status != "ok" || answer.Data.Allow {
+               return true, ""
+       }
+       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.
+func writeDenial(out io.Writer, agentID string, reason string) {
+       if agentID != "claude-code" {
+               return
+       }
+       _ = json.NewEncoder(out).Encode(map[string]any{
+               "hookSpecificOutput": map[string]any{
+                       "hookEventName":            "PreToolUse",
+                       "permissionDecision":       "deny",
+                       "permissionDecisionReason": reason,
+               },
+       })
+}
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 6ebafb0..112d82b 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -52,12 +52,13 @@ func ServeIfInvoked() {
        }
        timer := time.AfterFunc(hookLifetime, func() { os.Exit(0) })
        defer timer.Stop()
-       _ = Run(os.Args[2:], os.Stdin)
+       _ = Run(os.Args[2:], os.Stdin, os.Stdout)
        os.Exit(0)
 }
 
-// Run reads one Claude Code hook event and reports its normalized record.
-func Run(args []string, input io.Reader) error {
+// Run reads one hook event, decides it where the agent is waiting on a 
verdict,
+// and reports its normalized record.
+func Run(args []string, input io.Reader, output io.Writer) error {
        flags := flag.NewFlagSet(Subcommand, flag.ContinueOnError)
        flags.SetOutput(io.Discard)
        managed := flags.Bool("casbin-gateway-agent-monitor", false, 
"Gateway-managed monitor hook")
@@ -66,6 +67,7 @@ func Run(args []string, input io.Reader) error {
        agentPath := flags.String("agent-path", "", "agent installation path")
        owner := flags.String("user", "", "agent installation owner")
        ingestToken := flags.String("ingest-token", "", "credential presented 
to the Gateway record endpoint")
+       decisionURL := flags.String("decision-url", "", "Gateway endpoint that 
decides a tool call")
        if err := flags.Parse(args); err != nil {
                return err
        }
@@ -80,6 +82,20 @@ func Run(args []string, input io.Reader) error {
        if err := decoder.Decode(&event); err != nil {
                return err
        }
+       // The verdict comes before the record: the agent is held up until this 
hook
+       // answers, and the report is best effort either way.
+       if tool, ok := preToolEvent(*agentID, event); ok {
+               request := map[string]any{
+                       "agent":      *agentID,
+                       "tool":       tool,
+                       "sessionKey": stringValue(event["session_id"]),
+                       "toolUseId":  stringValue(event["tool_use_id"]),
+               }
+               if allow, reason := allowed(*decisionURL, *ingestToken, 
request); !allow {
+                       writeDenial(output, *agentID, reason)
+               }
+       }
+
        record := normalize(event, *agentPath, time.Now())
        if record == nil || *recordsURL == "" {
                return nil
diff --git a/agentmonitor/monitor.go b/agentmonitor/monitor.go
index a9f60af..acc1a2e 100644
--- a/agentmonitor/monitor.go
+++ b/agentmonitor/monitor.go
@@ -94,6 +94,26 @@ func MonitorAgentId(agentID string) string {
        return canonicalAgentID(agentID)
 }
 
+// sharedAgentIds are the front ends that report under one canonical id, keyed
+// by that id. They read the same configuration file, so one patched
+// installation speaks for all of them.
+var sharedAgentIds = map[string][]string{
+       "codex-cli": {"codex_vscode", "codex-vscode"},
+       "opencode":  {"opencode-desktop"},
+       "cursor":    {"cursor-agent"},
+}
+
+// SharedAgentIds is every agent id this installation covers, the canonical one
+// first. A hook is installed once per configuration file, so the id it reports
+// under is not the only one it acts for.
+func SharedAgentIds(agentID string) []string {
+       canonical := canonicalAgentID(agentID)
+       if canonical == "" {
+               return nil
+       }
+       return append([]string{canonical}, sharedAgentIds[canonical]...)
+}
+
 func canonicalAgentID(agentID string) string {
        switch strings.ToLower(strings.TrimSpace(agentID)) {
        case "codex_vscode", "codex-vscode":
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 27fc7b2..225b313 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -25,6 +25,10 @@ import (
 
 const claudeCodeHookTimeoutSeconds = 5
 
+// claudeCodeDecisionEvent is the one event whose answer Claude Code reads, and
+// so the only one installed as a blocking hook.
+const claudeCodeDecisionEvent = "PreToolUse"
+
 var claudeCodeHookEvents = []string{
        "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse",
        "PostToolUseFailure", "PermissionRequest", "PermissionDenied",
@@ -42,6 +46,10 @@ func (claudeCodePatcher) AgentId() string { return 
"claude-code" }
 
 func (claudeCodePatcher) Supported() bool { return true }
 
+// Decides: the PreToolUse hook is installed blocking, so Claude Code waits for
+// Gateway's verdict before it runs a tool.
+func (claudeCodePatcher) Decides() bool { return true }
+
 func (claudeCodePatcher) Patch(target Target) error {
        stateMutex.Lock()
        defer stateMutex.Unlock()
@@ -91,9 +99,9 @@ func (claudeCodePatcher) Unpatch(target Target) error {
 // the UI that gave no hint about what changes or whether a restart is needed.
 func (claudeCodePatcher) PatchNotice(patched bool) (string, string) {
        if patched {
-               return "Removes Gateway's audit-only Claude Code hooks.", 
"Restart any Claude Code session that is already running."
+               return "Removes Gateway's Claude Code hooks, and with them the 
check before each tool call.", "Restart any Claude Code session that is already 
running."
        }
-       return "Installs audit-only Claude Code hooks. Hooks observe events and 
never block an action.", "Restart any Claude Code session that is already 
running."
+       return "Installs Gateway's Claude Code 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 any Claude Code session that is already 
running."
 }
 
 func (claudeCodePatcher) Status(target Target) (Status, error) {
@@ -137,6 +145,10 @@ func claudeCodeHookCommand(target Target) (string, 
[]string, error) {
        if err != nil {
                return "", nil, err
        }
+       decision, err := decisionURL()
+       if err != nil {
+               return "", nil, err
+       }
        token, err := IssueIngestToken(target)
        if err != nil {
                return "", nil, err
@@ -146,6 +158,7 @@ func claudeCodeHookCommand(target Target) (string, 
[]string, error) {
                agenthook.OwnershipFlag,
                "--agent", "claude-code",
                "--records-url", url,
+               "--decision-url", decision,
                "--agent-path", target.Path,
                "--user", target.Owner,
                "--ingest-token", token,
@@ -169,7 +182,7 @@ func normalizeClaudeCodeHooks(config map[string]any, 
command string, args []stri
                if err != nil {
                        return fmt.Errorf("hooks.%s: %w", event, err)
                }
-               group := map[string]any{"hooks": 
[]any{newClaudeCodeHook(command, args)}}
+               group := map[string]any{"hooks": []any{newClaudeCodeHook(event, 
command, args)}}
                if hookEventSupportsMatcher(event) {
                        group["matcher"] = ""
                }
@@ -178,12 +191,15 @@ func normalizeClaudeCodeHooks(config map[string]any, 
command string, args []stri
        return nil
 }
 
-func newClaudeCodeHook(command string, args []string) map[string]any {
+func newClaudeCodeHook(event string, command string, args []string) 
map[string]any {
        return map[string]any{
                "type":    "command",
                "command": command,
                "args":    args,
-               "async":   true,
+               // Every event but the one Claude Code waits on for a verdict 
runs
+               // detached: an async hook's output is never read, so 
PreToolUse has to
+               // be the one that holds the session for as long as the 
decision takes.
+               "async":   event != claudeCodeDecisionEvent,
                "timeout": claudeCodeHookTimeoutSeconds,
        }
 }
@@ -234,7 +250,19 @@ func isCurrentClaudeCodeHook(handler map[string]any) bool {
                return false
        }
        command, _ := handler["command"].(string)
-       return argsAreCurrent(command, stringArray(handler["args"]))
+       if !argsAreCurrent(command, stringArray(handler["args"])) {
+               return false
+       }
+       // A hook installed before Gateway could decide a tool call carries no
+       // decision endpoint, and reporting it as current would leave it 
observing
+       // for good.
+       decision, err := decisionURL()
+       if err != nil {
+               return false
+       }
+       args := stringArray(handler["args"])
+       index := stringIndex(args, "--decision-url")
+       return index >= 0 && index+1 < len(args) && args[index+1] == decision
 }
 
 func hookEventSupportsMatcher(event string) bool {
diff --git a/agentpatch/endpoint.go b/agentpatch/endpoint.go
index cd5a6e1..ce9ffe9 100644
--- a/agentpatch/endpoint.go
+++ b/agentpatch/endpoint.go
@@ -24,17 +24,29 @@ import (
 
 const defaultHttpPort = "17000"
 
-// recordsURL is the loopback endpoint a patched installation reports to. A
-// malformed httpport would bake an unreachable URL into an agent's config, so
-// it is rejected while the operator can still see the error.
+// recordsURL is the loopback endpoint a patched installation reports to.
 func recordsURL() (string, error) {
+       return loopbackURL("/api/add-agent-record")
+}
+
+// decisionURL is the loopback endpoint a patched installation asks before it
+// runs a tool. It is what holds an agent whose model traffic never comes
+// through the proxy.
+func decisionURL() (string, error) {
+       return loopbackURL("/api/check-agent-tool")
+}
+
+// loopbackURL builds one of those. A malformed httpport would bake an
+// unreachable URL into an agent's config, so it is rejected while the operator
+// can still see the error.
+func loopbackURL(path string) (string, error) {
        port := strings.TrimSpace(conf.GetConfigString("httpport"))
        if port == "" {
                port = defaultHttpPort
        }
        number, err := strconv.Atoi(port)
        if err != nil || number <= 0 || number > 65535 {
-               return "", fmt.Errorf("cannot build the agent record endpoint: 
httpport %q is not a valid port", port)
+               return "", fmt.Errorf("cannot build the agent endpoint %s: 
httpport %q is not a valid port", path, port)
        }
-       return fmt.Sprintf("http://127.0.0.1:%d/api/add-agent-record";, number), 
nil
+       return fmt.Sprintf("http://127.0.0.1:%d%s";, number, path), nil
 }
diff --git a/agentpatch/opencode.go b/agentpatch/opencode.go
index cc80059..6050b21 100644
--- a/agentpatch/opencode.go
+++ b/agentpatch/opencode.go
@@ -50,6 +50,10 @@ func (p opencodePatcher) AgentId() string { return p.id }
 
 func (opencodePatcher) Supported() bool { return true }
 
+// Decides: the plugin asks Gateway in tool.execute.before and throws on a
+// refusal, so the call never runs.
+func (opencodePatcher) Decides() bool { return true }
+
 func (p opencodePatcher) Patch(target Target) error {
        path, err := p.pluginPath(target)
        if err != nil {
@@ -90,9 +94,13 @@ func (p opencodePatcher) Status(target Target) (Status, 
error) {
        if err != nil {
                return Status{}, err
        }
+       decision, err := decisionURL()
+       if err != nil {
+               return Status{}, err
+       }
        // A plugin left by an older Gateway, or by one listening on another 
port,
-       // reports nowhere. Re-patching is what rewrites it.
-       if !strings.Contains(string(data), jsonString(current)) {
+       // reports nowhere and decides nothing. Re-patching is what rewrites it.
+       if !strings.Contains(string(data), jsonString(current)) || 
!strings.Contains(string(data), jsonString(decision)) {
                return Status{Detail: "opencode plugin needs refresh"}, nil
        }
        if !IsApplied(target) {
@@ -103,10 +111,10 @@ func (p opencodePatcher) Status(target Target) (Status, 
error) {
 
 func (opencodePatcher) PatchNotice(patched bool) (string, string) {
        if patched {
-               return "Removes Gateway's audit-only opencode plugin. The CLI 
and the desktop app share it, so both stop reporting.",
+               return "Removes Gateway's opencode plugin. The CLI and the 
desktop app share it, so both stop reporting and stop being checked.",
                        "Restart any opencode session that is already running."
        }
-       return "Installs an audit-only opencode plugin. It observes sessions, 
tool calls and permission prompts, and changes none of them.",
+       return "Installs Gateway's opencode plugin. It observes sessions, tool 
calls and permission prompts, and refuses a tool call the permissions do not 
allow; an agent nobody has restricted is never held up.",
                "Restart any opencode session that is already running."
 }
 
@@ -125,6 +133,10 @@ func renderOpencodePlugin(target Target) (string, error) {
        if err != nil {
                return "", err
        }
+       decision, err := decisionURL()
+       if err != nil {
+               return "", err
+       }
        token, err := IssueIngestToken(monitorTarget(target))
        if err != nil {
                return "", err
@@ -134,6 +146,7 @@ func renderOpencodePlugin(target Target) (string, error) {
        for placeholder, value := range map[string]string{
                "__CASBIN_GATEWAY_AGENT__":               
jsonString(opencodeMonitorId),
                "__CASBIN_GATEWAY_RECORDS_URL__":         jsonString(url),
+               "__CASBIN_GATEWAY_DECISION_URL__":        jsonString(decision),
                "__CASBIN_GATEWAY_AGENT_PATH__":          
jsonString(target.Path),
                "__CASBIN_GATEWAY_OWNER__":               
jsonString(target.Owner),
                "__CASBIN_GATEWAY_INGEST_TOKEN__":        jsonString(token),
diff --git a/agentpatch/opencode_plugin.js b/agentpatch/opencode_plugin.js
index 3b27fb2..ebd7c09 100644
--- a/agentpatch/opencode_plugin.js
+++ b/agentpatch/opencode_plugin.js
@@ -12,17 +12,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Audit-only opencode plugin. Every hook observes and returns; none of them
-// reads or writes the output object opencode passes in, so no tool call,
-// permission or message is changed by monitoring being on.
+// Gateway's opencode plugin. Every hook but one observes and returns, changing
+// nothing about the session. The exception is tool.execute.before, which asks
+// Gateway whether the call is allowed and throws when it is not: that is what
+// holds an opencode pointed at a provider Gateway never sees.
 
 const AGENT = __CASBIN_GATEWAY_AGENT__;
 const RECORDS_URL = __CASBIN_GATEWAY_RECORDS_URL__;
+const DECISION_URL = __CASBIN_GATEWAY_DECISION_URL__;
 const AGENT_PATH = __CASBIN_GATEWAY_AGENT_PATH__;
 const OWNER = __CASBIN_GATEWAY_OWNER__;
 const INGEST_TOKEN = __CASBIN_GATEWAY_INGEST_TOKEN__;
 const INGEST_TOKEN_HEADER = __CASBIN_GATEWAY_INGEST_TOKEN_HEADER__;
 const POST_TIMEOUT_MS = 3000;
+// The session waits on the verdict, so a Gateway that is slow to answer is
+// treated as one that is not running.
+const DECIDE_TIMEOUT_MS = 2000;
 const MAX_OBJECT_CHARS = 16 * 1024;
 const MAX_TEXT_CHARS = 4 * 1024;
 
@@ -92,6 +97,30 @@ function post(record) {
   }
 }
 
+// decide asks Gateway whether a tool call may go ahead. Anything that goes
+// wrong - Gateway down, a timeout, a body that will not parse - allows the
+// call: a plugin that cannot get a verdict must not stop opencode working.
+async function decide(tool, sessionID, callID) {
+  if (!DECISION_URL || !tool) return "";
+  try {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), DECIDE_TIMEOUT_MS);
+    const headers = { "Content-Type": "application/json" };
+    if (INGEST_TOKEN) headers[INGEST_TOKEN_HEADER] = INGEST_TOKEN;
+    const response = await fetch(DECISION_URL, {
+      method: "POST",
+      headers,
+      body: JSON.stringify({ agent: AGENT, tool, sessionKey: sessionID || "", 
toolUseId: callID || "" }),
+      signal: controller.signal,
+    }).finally(() => clearTimeout(timer));
+    const answer = await response.json();
+    if (answer?.status !== "ok" || answer?.data?.allow !== false) return "";
+    return answer.data.reason || `the permissions of agent ${AGENT} do not 
allow ${tool}`;
+  } catch {
+    return "";
+  }
+}
+
 // promptText joins the text parts of a message. Attachments and file contents
 // are left out: the record says what was asked, not what was pasted in.
 function promptText(parts) {
@@ -149,6 +178,10 @@ export const CasbinGatewayMonitor = async () => {
         toolName: input?.tool || "",
         object: objectFor({ args: output?.args }),
       });
+      // Throwing is how a plugin refuses a call: opencode abandons the tool 
and
+      // hands the message back to the model.
+      const refusal = await decide(input?.tool, input?.sessionID, 
input?.callID);
+      if (refusal) throw new Error(refusal);
     },
 
     "tool.execute.after": async (input, output) => {
diff --git a/agentpatch/patch.go b/agentpatch/patch.go
index bc7ede6..0170391 100644
--- a/agentpatch/patch.go
+++ b/agentpatch/patch.go
@@ -43,11 +43,15 @@ func monitorTarget(target Target) Target {
 
 // Status is the monitoring state shown beside an agent installation.
 type Status struct {
-       Supported bool   `json:"supported"`
-       Patched   bool   `json:"patched"`
-       Detail    string `json:"detail,omitempty"`
-       Notice    string `json:"notice,omitempty"`
-       Followup  string `json:"followup,omitempty"`
+       Supported bool `json:"supported"`
+       Patched   bool `json:"patched"`
+       // Decides reports that this installation's hook is in place and asks
+       // Gateway before a tool call, which is what holds an agent whose 
requests
+       // never come through the proxy.
+       Decides  bool   `json:"decides"`
+       Detail   string `json:"detail,omitempty"`
+       Notice   string `json:"notice,omitempty"`
+       Followup string `json:"followup,omitempty"`
 }
 
 type patcher interface {
@@ -62,6 +66,13 @@ type noticer interface {
        PatchNotice(bool) (notice string, followup string)
 }
 
+// decider is a patcher whose hook asks Gateway before a tool runs, and so
+// enforces this agent's permissions wherever its model traffic goes. A patcher
+// that only observes does not implement it.
+type decider interface {
+       Decides() bool
+}
+
 var patchers = map[string]patcher{}
 
 // register panics on a duplicate id: two patchers for one agent means one of
@@ -88,6 +99,9 @@ func StatusOf(target Target) Status {
                status = Status{Detail: err.Error()}
        }
        status.Supported = patcher.Supported()
+       if source, ok := patcher.(decider); ok {
+               status.Decides = status.Patched && source.Decides()
+       }
        if status.Supported {
                if source, ok := patcher.(noticer); ok {
                        status.Notice, status.Followup = 
source.PatchNotice(status.Patched)
diff --git a/controllers/agent.go b/controllers/agent.go
index c3b8aac..f2e5903 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -32,6 +32,8 @@ import (
        "github.com/apache/casbin-gateway/agentprovider"
        "github.com/apache/casbin-gateway/object"
        "github.com/apache/casbin-gateway/service"
+       "github.com/apache/casbin-gateway/util"
+       "github.com/beego/beego"
 )
 
 type discoveredAgent struct {
@@ -362,6 +364,70 @@ func sessionSeenKey(agentId string, sessionKey string) 
string {
        return agentId + "/" + sessionKey
 }
 
+// CheckAgentTool decides one tool call for the hook an agent runs before
+// executing it. This is the enforcement point for the traffic that never comes
+// through the proxy: whatever provider an agent talks to, the tool still runs
+// on this machine, and the hook asks here first.
+//
+// It authenticates the way record ingestion does, with the per-installation
+// credential issued at Patch time, and it answers "allowed" rather than an
+// error whenever the decision cannot be made - a hook that cannot get a 
verdict
+// must not wedge every session on the machine.
+func (c *ApiController) CheckAgentTool() {
+       if _, ok := c.directLoopbackClient(); !ok {
+               c.ResponseError("agent tool checks are limited to direct 
loopback requests")
+               return
+       }
+       tokenAgentId, ok := 
agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader))
+       if !ok {
+               c.ResponseError("an agent tool check requires a valid 
installation token")
+               return
+       }
+
+       var form struct {
+               Agent      string `json:"agent"`
+               Tool       string `json:"tool"`
+               SessionKey string `json:"sessionKey"`
+               ToolUseId  string `json:"toolUseId"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if form.Agent == "" {
+               c.ResponseError("agent is required")
+               return
+       }
+       // The token decides which agent a caller may ask for, so a compromised 
hook
+       // cannot have another installation's rules applied to its calls.
+       if tokenAgentId != "" && !strings.EqualFold(form.Agent, tokenAgentId) {
+               c.ResponseError("agent does not match the installation this 
token was issued for")
+               return
+       }
+
+       agentIds := agentmonitor.SharedAgentIds(form.Agent)
+       allowed, reason, err := object.CheckAgentTool(agentIds, form.Tool)
+       if err != nil {
+               beego.Error("agent tool check failed, the call was allowed:", 
err)
+       }
+       if !allowed {
+               // A refusal is worth a record of its own: the agent tells the 
model it
+               // was blocked, and nothing else would show it on the records 
page.
+               agentmonitor.AddRecord(&agentmonitor.Record{
+                       Agent:       agentmonitor.MonitorAgentId(form.Agent),
+                       CreatedTime: util.GetCurrentTime(),
+                       EventType:   "permission",
+                       Action:      "denied",
+                       Outcome:     "denied",
+                       SessionKey:  form.SessionKey,
+                       ToolUseId:   form.ToolUseId,
+                       ToolName:    form.Tool,
+                       Detail:      reason,
+               })
+       }
+       c.ResponseOk(map[string]any{"allow": allowed, "reason": reason})
+}
+
 // AddAgentRecord accepts reports from a hook or MCP process launched locally 
by
 // Gateway. Those processes have no browser session, so they authenticate with
 // the per-installation credential issued at Patch time. Loopback alone is not 
a
diff --git a/object/permission_check.go b/object/permission_check.go
new file mode 100644
index 0000000..00faef7
--- /dev/null
+++ b/object/permission_check.go
@@ -0,0 +1,50 @@
+// 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.
+
+// This file answers the second enforcement point. The proxy takes a forbidden
+// tool out of the request before a model is offered it; the hook an agent runs
+// before executing a tool asks here instead, which is what holds an agent 
whose
+// traffic never comes through Gateway at all.
+
+package object
+
+import "fmt"
+
+// CheckAgentTool decides one tool call. The ids are the agents the caller
+// speaks for: one configuration file can serve several front ends, and the
+// strictest of their rules is the one that stands.
+//
+// It answers "allowed" for an agent nobody has restricted, and for a tool that
+// falls under no switch, so a hook installed everywhere costs nothing until
+// someone turns permissions on.
+func CheckAgentTool(agentIds []string, tool string) (bool, string, error) {
+       if tool == "" {
+               return true, "", nil
+       }
+
+       for _, agentId := range agentIds {
+               guard, err := LoadAgentGuard(agentId)
+               if err != nil {
+                       // A rule that cannot be read is not one to stop an 
agent on: the
+                       // caller logs this and the tool call goes ahead.
+                       return true, "", err
+               }
+               if guard == nil || guard.AllowTool(tool) {
+                       continue
+               }
+               return false, fmt.Sprintf("the permissions of agent %s do not 
allow %s (%s)",
+                       agentId, tool, ToolItemOf(tool)), nil
+       }
+       return true, "", nil
+}
diff --git a/routers/router.go b/routers/router.go
index 9714398..9dfbcd3 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -92,6 +92,7 @@ func initAPI() {
        beego.Router("/api/get-agent-session", &controllers.ApiController{}, 
"GET:GetAgentSession")
        beego.Router("/api/get-agent-usage", &controllers.ApiController{}, 
"GET:GetAgentUsage")
        beego.Router("/api/add-agent-record", &controllers.ApiController{}, 
"POST:AddAgentRecord")
+       beego.Router("/api/check-agent-tool", &controllers.ApiController{}, 
"POST:CheckAgentTool")
        beego.Router("/api/get-agent-configs", &controllers.ApiController{}, 
"GET:GetAgentConfigs")
        beego.Router("/api/get-agent-config-item", 
&controllers.ApiController{}, "GET:GetAgentConfigItem")
        beego.Router("/api/delete-agent-config-item", 
&controllers.ApiController{}, "POST:DeleteAgentConfigItem")
diff --git a/web/src/components/PermissionCard.tsx 
b/web/src/components/PermissionCard.tsx
index 971a900..a8b778a 100644
--- a/web/src/components/PermissionCard.tsx
+++ b/web/src/components/PermissionCard.tsx
@@ -38,7 +38,14 @@ import {Card, CardContent, CardHeader, CardTitle} from 
"@/components/ui/card";
 import {Switch} from "@/components/ui/switch";
 import {TagsInput} from "@/components/ui/tags-input";
 import {Textarea} from "@/components/ui/textarea";
-import {agentRoutedHere, directMode, gatewayMode, routedAgentId} from 
"@/lib/agents";
+import {
+  agentDecidesHere,
+  agentRoutedHere,
+  agentToolsEnforced,
+  directMode,
+  gatewayMode,
+  routedAgentId,
+} from "@/lib/agents";
 import {providerIdOf} from "@/lib/providers";
 import {cn} from "@/lib/utils";
 import type {
@@ -285,14 +292,21 @@ function AdvancedView({
   );
 }
 
-/** Why the rules below are reaching nothing. An agent whose own configuration
- *  does not name its gateway endpoint never relays a request through Gateway,
- *  so its switches decide nothing until it is bound again. */
+/** Where the rules below are actually applied. An agent that relays through
+ *  Gateway is held in the request; one that does not is held by the hook
+ *  Gateway installed in it, which only decides tool calls; an agent with
+ *  neither is held nowhere, and says so. */
 function RoutingWarning({agent}: {agent: Agent}) {
   if (agentRoutedHere(agent)) {
     return null;
   }
 
+  if (agentDecidesHere(agent)) {
+    return (
+      <p className="text-sm text-muted-foreground">{i18next.t("agent:Hook 
enforced hint")}</p>
+    );
+  }
+
   const routed = routedAgentId(agent);
   return (
     <p className="text-sm text-warning">
@@ -399,7 +413,7 @@ export function PermissionCard({
             {i18next.t("agent:Permissions")}
             {!permission.enabled ? (
               <Badge variant="muted">{i18next.t("agent:Unrestricted")}</Badge>
-            ) : !agentRoutedHere(agent) ? (
+            ) : !agentToolsEnforced(agent) ? (
               <Badge variant="warning">{i18next.t("agent:Not 
enforced")}</Badge>
             ) : blocked.length === 0 ? (
               <Badge variant="muted">{i18next.t("agent:Enforced")}</Badge>
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index c061cc0..3cc9b55 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -179,14 +179,28 @@ export function routedAgentId(agent: Agent) {
 }
 
 /**
- * Whether what this agent calls is its own gateway endpoint. Only the requests
- * that arrive there are held to its permissions, so an agent this is false for
- * is not restricted by them at all, whatever its switches say.
+ * Whether what this agent calls is its own gateway endpoint. The requests that
+ * arrive there are the ones the proxy can hold: it takes a forbidden tool out
+ * of the body, and it is the only place the model and provider rules are read.
  */
 export function agentRoutedHere(agent: Agent) {
   return routedAgentId(agent) === agent.agentId;
 }
 
+/**
+ * Whether this agent's own hook asks Gateway before it runs a tool. That is 
the
+ * other enforcement point, and the one that does not care where the agent 
sends
+ * its requests - but it only decides tool calls.
+ */
+export function agentDecidesHere(agent: Agent) {
+  return agent.decides === true;
+}
+
+/** Whether the tool switches reach this agent at all, either way. */
+export function agentToolsEnforced(agent: Agent) {
+  return agentRoutedHere(agent) || agentDecidesHere(agent);
+}
+
 /**
  * Whether an agent can be pointed at a provider serving this wire format.
  * Through the gateway any provider will do: the proxy translates between the
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index db4389b..b9c168b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -141,6 +141,7 @@
     "Gemini gateway only hint": "The Gemini CLI speaks only Google's 
generateContent API, which no provider here serves. Gateway serves it by 
translating to the API the provider answers, so the CLI cannot be pointed at a 
provider directly.",
     "Getting started": "Getting started",
     "Home folder": "Home",
+    "Hook enforced hint": "This agent does not route through Gateway, so the 
switches below are enforced by the hook Gateway installed in it: a tool call 
they do not allow is refused before it runs. The model and provider rules only 
hold requests that come through Gateway.",
     "Image": "Image",
     "In use": "In use",
     "Install": "Install",
@@ -302,7 +303,7 @@
     "Tool": "Tool",
     "Tool call": "Tool call",
     "Tool error": "Tool error",
-    "Tool hint": "A switch turned off takes those tools out of the request 
before the model ever sees them, so the agent never gets to call them. Set a 
whole group at once with the switch on its header.",
+    "Tool hint": "A switch turned off takes those tools out of the request 
before the model ever sees them, and the hook Gateway installs in the agent 
refuses the call if it is made anyway. Set a whole group at once with the 
switch on its header.",
     "Tool result": "Tool result",
     "Tool use ID": "Tool use ID",
     "Transcript truncated": "Some of this session was too large to show, so 
what follows is not all of it",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 2fc8d83..aa0fef4 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -141,6 +141,7 @@
     "Gemini gateway only hint": "Gemini CLI 只会说 Google 的 generateContent 
API,这里没有供应商提供该 API。Gateway 会把它翻译成供应商应答的 API,所以 Gemini CLI 无法直连供应商。",
     "Getting started": "快速上手",
     "Home folder": "主目录",
+    "Hook enforced hint": 
"该智能体不经网关转发,因此下面的开关由网关装在它里面的钩子来拦截:不允许的工具调用在执行前就被拒绝。模型和供应商规则只对经网关转发的请求生效。",
     "Image": "图片",
     "In use": "使用中",
     "Install": "安装",
@@ -302,7 +303,7 @@
     "Tool": "工具",
     "Tool call": "工具调用",
     "Tool error": "工具报错",
-    "Tool hint": "关掉的开关会在请求发给模型之前把对应的工具移除,智能体拿不到,也就无法调用。点分组标题上的开关可以整组一起设。",
+    "Tool hint": 
"关掉的开关会在请求发给模型之前把对应的工具移除;即便调用真的发生了,网关装在智能体里的钩子也会在执行前拒绝它。点分组标题上的开关可以整组一起设。",
     "Tool result": "工具返回",
     "Tool use ID": "工具调用 ID",
     "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来",
diff --git a/web/src/pages/PermissionsPage.tsx 
b/web/src/pages/PermissionsPage.tsx
index db386a5..24589ae 100644
--- a/web/src/pages/PermissionsPage.tsx
+++ b/web/src/pages/PermissionsPage.tsx
@@ -28,7 +28,7 @@ import {PageContainer, PageHeader} from 
"@/components/shared/page-header";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
-import {agentRoutedHere, useAgents} from "@/lib/agents";
+import {agentToolsEnforced, useAgents} from "@/lib/agents";
 import {cn} from "@/lib/utils";
 import type {Account, Agent, AgentPermission, Provider} from "@/types";
 
@@ -66,7 +66,7 @@ function AgentRow({
         <Badge variant="muted" className="shrink-0 font-normal">
           {i18next.t("agent:Unrestricted")}
         </Badge>
-      ) : !agentRoutedHere(agent) ? (
+      ) : !agentToolsEnforced(agent) ? (
         <Badge variant="warning" className="shrink-0 font-normal">
           {i18next.t("agent:Not enforced")}
         </Badge>
diff --git a/web/src/types.ts b/web/src/types.ts
index 75c2ce6..054611b 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -173,6 +173,9 @@ export interface Agent {
   account?: AgentAccount;
   supported: boolean;
   patched: boolean;
+  /** Whether this installation's hook asks Gateway before it runs a tool, 
which
+   *  is what holds an agent whose requests never come through the proxy. */
+  decides?: boolean;
   detail?: string;
   notice?: string;
   followup?: string;


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

Reply via email to