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 b917f9a  feat(permission): let each agent be switched on and off per 
tool, model and provider
b917f9a is described below

commit b917f9a42c91c68417ecbb8884ac990518c72039
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 01:19:06 2026 +0800

    feat(permission): let each agent be switched on and off per tool, model and 
provider
---
 controllers/permission.go             |  92 +++++++++
 controllers/proxy.go                  |  44 +++++
 go.mod                                |   3 +
 go.sum                                |   9 +
 object/ormer.go                       |   7 +
 object/permission.go                  | 231 ++++++++++++++++++++++
 object/permission_casbin.go           | 284 +++++++++++++++++++++++++++
 object/permission_tools.go            | 287 ++++++++++++++++++++++++++++
 object/snapshot.go                    |  82 +++++++-
 routers/router.go                     |   2 +
 web/src/backend/PermissionBackend.ts  |  29 +++
 web/src/components/PermissionCard.tsx | 348 ++++++++++++++++++++++++++++++++++
 web/src/locales/en/data.json          |  29 +++
 web/src/locales/zh/data.json          |  29 +++
 web/src/pages/AgentDetailPage.tsx     |   3 +
 web/src/types.ts                      |  64 ++++++-
 16 files changed, 1539 insertions(+), 4 deletions(-)

diff --git a/controllers/permission.go b/controllers/permission.go
new file mode 100644
index 0000000..569dd72
--- /dev/null
+++ b/controllers/permission.go
@@ -0,0 +1,92 @@
+// 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 controllers
+
+import (
+       "encoding/json"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/object"
+)
+
+// agentPermissionInfo is one agent's permissions with everything the card 
needs
+// to draw them: the groups it has a switch for, and the casbin model and 
policy
+// they compile to, which is what the advanced view shows.
+type agentPermissionInfo struct {
+       Permission *object.AgentPermission `json:"permission"`
+       Groups     []object.ToolGroup      `json:"groups"`
+       Model      string                  `json:"model"`
+       Policy     []string                `json:"policy"`
+}
+
+func newAgentPermissionInfo(permission *object.AgentPermission) 
*agentPermissionInfo {
+       return &agentPermissionInfo{
+               Permission: permission,
+               Groups:     object.ToolGroups(),
+               Model:      object.PermissionModelText,
+               Policy:     permission.PolicyText(),
+       }
+}
+
+// GetAgentPermission answers with what one agent is allowed to ask for. An
+// agent nobody has configured answers with the unrestricted default rather 
than
+// with nothing, so the card renders the same either way.
+func (c *ApiController) GetAgentPermission() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       agentId := c.Input().Get("agentId")
+       if agentId == "" {
+               c.ResponseError("the agent id is empty")
+               return
+       }
+
+       permission, err := object.GetAgentPermission(agentId)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(newAgentPermissionInfo(permission))
+}
+
+// UpdateAgentPermission stores the switches of one agent's Permissions card.
+// The policy compiled from them decides the next request that agent relays.
+func (c *ApiController) UpdateAgentPermission() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId    string                  `json:"agentId"`
+               Permission *object.AgentPermission `json:"permission"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if !agent.IsKnownAgentId(form.AgentId) {
+               c.ResponseError("unknown agent: " + form.AgentId)
+               return
+       }
+
+       if err := object.UpdateAgentPermission(form.AgentId, form.Permission); 
err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(newAgentPermissionInfo(form.Permission))
+}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index a09b5fd..127b11b 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -305,6 +305,19 @@ func (c *ApiController) forwardByAgent(route *proxyRoute) {
        }
        defer c.finishLlmRecord(route)
 
+       // What this agent is allowed to ask for. A guard is nil for an agent 
whose
+       // permissions are off, which is every agent until someone turns them 
on.
+       guard, err := object.LoadAgentGuard(agentId)
+       if err != nil {
+               beego.Error("agent permission lookup failed:", err)
+               route.recordOutcome(http.StatusBadGateway, err.Error())
+               c.writeProxyError(route.codec, http.StatusBadGateway, 
"server_error", err.Error())
+               return
+       }
+       if guard != nil && !c.allowAgentRequest(guard, agentId, route) {
+               return
+       }
+
        // The whole chain is forwarded to, so a bound provider that is down 
fails
        // over to the agent's fallbacks instead of failing the request.
        providers, err := object.GetProvidersByAgent(agentId)
@@ -320,9 +333,40 @@ func (c *ApiController) forwardByAgent(route *proxyRoute) {
                return
        }
 
+       if guard != nil {
+               allowed, reason := guard.FilterProviders(providers)
+               if len(allowed) == 0 {
+                       route.recordOutcome(http.StatusForbidden, reason)
+                       c.writeProxyError(route.codec, http.StatusForbidden, 
"permission_error", reason)
+                       return
+               }
+               providers = allowed
+       }
+
        c.forwardToProviders(providers, route)
 }
 
+// allowAgentRequest holds one relayed request to the agent's permissions: a
+// model it may not ask for is refused outright, and the tools of a group it 
may
+// not use are taken back out of the body before any provider sees it. A tool
+// the model is never offered is one the agent cannot run.
+func (c *ApiController) allowAgentRequest(guard *object.AgentGuard, agentId 
string, route *proxyRoute) bool {
+       if !guard.AllowModel(route.model) {
+               message := fmt.Sprintf("the permissions of agent %s do not 
allow the model %s", agentId, route.model)
+               route.recordOutcome(http.StatusForbidden, message)
+               c.writeProxyError(route.codec, http.StatusForbidden, 
"permission_error", message)
+               return false
+       }
+
+       if filtered, removed := guard.FilterTools(route.body); len(removed) > 0 
{
+               // The record is written from the body, so it shows what was 
sent rather
+               // than what the agent asked to send.
+               route.body = filtered
+               beego.Info("agent", agentId, "is not allowed these tools, they 
were removed from the request:", strings.Join(removed, ", "))
+       }
+       return true
+}
+
 func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool) 
{
        if !c.allowRelayFor(target) {
                return nil, false
diff --git a/go.mod b/go.mod
index dfb6e57..0e995c1 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
        github.com/BurntSushi/toml v0.3.1
        github.com/Masterminds/squirrel v1.5.4
        github.com/beego/beego v1.12.12
+       github.com/casbin/casbin/v2 v2.135.0
        github.com/casdoor/casdoor-go-sdk v0.52.0
        github.com/denisenkom/go-mssqldb v0.10.0
        github.com/go-mysql-org/go-mysql v1.7.0
@@ -24,6 +25,8 @@ require (
 
 require (
        github.com/beorn7/perks v1.0.1 // indirect
+       github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
+       github.com/casbin/govaluate v1.3.0 // indirect
        github.com/cespare/xxhash/v2 v2.1.1 // indirect
        github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
        github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // 
indirect
diff --git a/go.sum b/go.sum
index 1f61bc1..8a6d250 100644
--- a/go.sum
+++ b/go.sum
@@ -21,8 +21,14 @@ github.com/beorn7/perks 
v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
 github.com/beorn7/perks v1.0.0/go.mod 
h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod 
h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmatcuk/doublestar/v4 v4.6.1 
h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod 
h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod 
h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
 github.com/casbin/casbin v1.7.0/go.mod 
h1:c67qKN6Oum3UF5Q1+BByfFxkwKvhwW57ITjqwtzR1KE=
+github.com/casbin/casbin/v2 v2.135.0 
h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
+github.com/casbin/casbin/v2 v2.135.0/go.mod 
h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
+github.com/casbin/govaluate v1.3.0 
h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
+github.com/casbin/govaluate v1.3.0/go.mod 
h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
 github.com/casdoor/casdoor-go-sdk v0.52.0 
h1:UKcZfczO3U5H7md6bTtI6zIJzeorBrbg/WE8Q4qsczE=
 github.com/casdoor/casdoor-go-sdk v0.52.0/go.mod 
h1:cMnkCQJgMYpgAlgEx8reSt1AVaDIQLcJ1zk5pzBaz+4=
 github.com/cespare/xxhash/v2 v2.1.1 
h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
@@ -66,6 +72,8 @@ github.com/golang-jwt/jwt/v4 v4.5.0 
h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOW
 github.com/golang-jwt/jwt/v4 v4.5.0/go.mod 
h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe 
h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod 
h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
+github.com/golang/mock v1.4.4/go.mod 
h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/golang/protobuf v1.2.0/go.mod 
h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod 
h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod 
h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -297,6 +305,7 @@ golang.org/x/text v0.14.0 
h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod 
h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod 
h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod 
h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod 
h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod 
h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod 
h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod 
h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
diff --git a/object/ormer.go b/object/ormer.go
index 635a6ce..fc51463 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -276,6 +276,13 @@ func (a *Ormer) createTable() {
                panic(err)
        }
 
+       // What each agent is allowed to ask for, as the switches of its 
Permissions
+       // card are set. The casbin policy is compiled from these rows.
+       err = a.Engine.Sync2(new(AgentPermission))
+       if err != nil {
+               panic(err)
+       }
+
        err = a.Engine.Sync2(new(LlmRecord))
        if err != nil {
                panic(err)
diff --git a/object/permission.go b/object/permission.go
new file mode 100644
index 0000000..5a5042d
--- /dev/null
+++ b/object/permission.go
@@ -0,0 +1,231 @@
+// 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 object
+
+import (
+       "errors"
+       "fmt"
+       "strings"
+
+       "github.com/apache/casbin-gateway/util"
+       "github.com/xorm-io/core"
+)
+
+// How a list of models or providers is read. "all" ignores the list, "allow"
+// permits nothing but what it holds, and "deny" permits everything else.
+const (
+       ListAll   = "all"
+       ListAllow = "allow"
+       ListDeny  = "deny"
+)
+
+// AgentPermission is what one agent is allowed to ask the gateway for. It is
+// the friendly form of the rules: the switches of the Permissions card are
+// stored as they are set, and compiled into the casbin policy that decides
+// every request. See permission_casbin.go for that compilation.
+type AgentPermission struct {
+       Owner       string `xorm:"varchar(100) notnull pk" json:"owner"`
+       Name        string `xorm:"varchar(100) notnull pk" json:"name"`
+       CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+       UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+       // Enabled turns the rules on. An agent with it off is left 
unrestricted,
+       // which is what every agent is until someone says otherwise.
+       Enabled bool `xorm:"bool" json:"enabled"`
+
+       // ModelMode reads Models: ListAll, ListAllow or ListDeny. An entry may 
end
+       // in "*", so "claude-opus-*" covers a whole family.
+       ModelMode string   `xorm:"varchar(20)" json:"modelMode"`
+       Models    []string `xorm:"mediumtext" json:"models"`
+
+       // ProviderMode reads Providers, which hold "owner/name" provider ids.
+       ProviderMode string   `xorm:"varchar(20)" json:"providerMode"`
+       Providers    []string `xorm:"mediumtext" json:"providers"`
+
+       // Tools is one entry per tool group, false where the group is switched 
off.
+       // A group that is missing is allowed, so a group added in a later 
version
+       // does not silently block the agents configured before it existed.
+       Tools map[string]bool `xorm:"mediumtext" json:"tools"`
+
+       // Rules are extra casbin policy lines, written by hand on the advanced 
view
+       // and appended to the compiled ones. Each is "sub, obj, act, eft".
+       Rules []string `xorm:"mediumtext" json:"rules"`
+}
+
+func (permission *AgentPermission) GetId() string {
+       return fmt.Sprintf("%s/%s", permission.Owner, permission.Name)
+}
+
+// DefaultAgentPermission is what an agent nobody has configured is held to:
+// nothing at all.
+func DefaultAgentPermission(agentId string) *AgentPermission {
+       return &AgentPermission{
+               Owner:        AgentOwner,
+               Name:         agentId,
+               Enabled:      false,
+               ModelMode:    ListAll,
+               Models:       []string{},
+               ProviderMode: ListAll,
+               Providers:    []string{},
+               Tools:        map[string]bool{},
+               Rules:        []string{},
+       }
+}
+
+// GetAgentPermission reads one agent's permissions, answering with the default
+// ones when it has none stored.
+func GetAgentPermission(agentId string) (*AgentPermission, error) {
+       if agentId == "" {
+               return nil, errors.New("the agent id is empty")
+       }
+
+       permission := &AgentPermission{Owner: AgentOwner, Name: agentId}
+       existed, err := ormer.Engine.Get(permission)
+       if err != nil {
+               return nil, err
+       }
+       if !existed {
+               return DefaultAgentPermission(agentId), nil
+       }
+
+       normalizeAgentPermission(permission)
+       return permission, nil
+}
+
+// GetAgentPermissions returns every stored permission, keyed by agent id. The
+// agents nobody has configured are absent rather than listed as unrestricted.
+func GetAgentPermissions() (map[string]*AgentPermission, error) {
+       permissions := []*AgentPermission{}
+       if err := ormer.Engine.Where("owner = ?", 
AgentOwner).Find(&permissions); err != nil {
+               return nil, err
+       }
+
+       result := map[string]*AgentPermission{}
+       for _, permission := range permissions {
+               normalizeAgentPermission(permission)
+               result[permission.Name] = permission
+       }
+       return result, nil
+}
+
+// UpdateAgentPermission stores one agent's permissions, creating the row on
+// first save. The enforcer built from them is dropped, so the next request is
+// decided by what was just saved.
+func UpdateAgentPermission(agentId string, permission *AgentPermission) error {
+       if agentId == "" {
+               return errors.New("the agent id is empty")
+       }
+       if err := validateAgentPermission(permission); err != nil {
+               return err
+       }
+
+       permission.Owner = AgentOwner
+       permission.Name = agentId
+       permission.UpdatedTime = util.GetCurrentTime()
+       normalizeAgentPermission(permission)
+
+       stored := &AgentPermission{Owner: AgentOwner, Name: agentId}
+       existed, err := ormer.Engine.Get(stored)
+       if err != nil {
+               return err
+       }
+
+       if !existed {
+               permission.CreatedTime = permission.UpdatedTime
+               if _, err = ormer.Engine.Insert(permission); err != nil {
+                       return err
+               }
+       } else {
+               permission.CreatedTime = stored.CreatedTime
+               // Cols() is what writes an emptied list or a switch turned 
off: xorm
+               // skips zero values otherwise.
+               _, err = ormer.Engine.ID(core.PK{AgentOwner, agentId}).
+                       Cols("enabled", "model_mode", "models", 
"provider_mode", "providers", "tools", "rules", "updated_time").
+                       Update(permission)
+               if err != nil {
+                       return err
+               }
+       }
+
+       dropAgentEnforcer(agentId)
+       return nil
+}
+
+// validateAgentPermission refuses what would be stored but never understood,
+// so a typo fails at the form rather than on the next relayed request.
+func validateAgentPermission(permission *AgentPermission) error {
+       if permission == nil {
+               return errors.New("the permission is empty")
+       }
+
+       for _, mode := range []string{permission.ModelMode, 
permission.ProviderMode} {
+               switch mode {
+               case "", ListAll, ListAllow, ListDeny:
+               default:
+                       return fmt.Errorf("invalid list mode: %s", mode)
+               }
+       }
+
+       for _, rule := range permission.Rules {
+               if strings.TrimSpace(rule) == "" {
+                       continue
+               }
+               if _, err := parsePolicyRule(rule); err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+// normalizeAgentPermission fills in what an older row, or a client that left a
+// field out, does not carry.
+func normalizeAgentPermission(permission *AgentPermission) {
+       if permission.ModelMode == "" {
+               permission.ModelMode = ListAll
+       }
+       if permission.ProviderMode == "" {
+               permission.ProviderMode = ListAll
+       }
+       if permission.Models == nil {
+               permission.Models = []string{}
+       }
+       if permission.Providers == nil {
+               permission.Providers = []string{}
+       }
+       if permission.Tools == nil {
+               permission.Tools = map[string]bool{}
+       }
+       if permission.Rules == nil {
+               permission.Rules = []string{}
+       }
+
+       permission.Models = trimList(permission.Models)
+       permission.Providers = trimList(permission.Providers)
+       permission.Rules = trimList(permission.Rules)
+}
+
+func trimList(values []string) []string {
+       result := []string{}
+       seen := map[string]bool{"": true}
+       for _, value := range values {
+               value = strings.TrimSpace(value)
+               if seen[value] {
+                       continue
+               }
+               seen[value] = true
+               result = append(result, value)
+       }
+       return result
+}
diff --git a/object/permission_casbin.go b/object/permission_casbin.go
new file mode 100644
index 0000000..8f20c6d
--- /dev/null
+++ b/object/permission_casbin.go
@@ -0,0 +1,284 @@
+// 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 turns the switches of one agent's Permissions card into the casbin
+// policy that decides its requests. The switches are what is stored; the 
policy
+// is what is enforced, and the advanced view shows it as it is compiled here.
+
+package object
+
+import (
+       "fmt"
+       "strings"
+       "sync"
+
+       "github.com/casbin/casbin/v2"
+       "github.com/casbin/casbin/v2/model"
+)
+
+// PermissionModelText is the casbin model every agent is enforced with. The
+// effect is casbin's allow-and-deny: a request needs a rule allowing it and no
+// rule denying it, which is what lets one "everything" rule stand beside the
+// exceptions to it.
+const PermissionModelText = `[request_definition]
+r = sub, obj, act
+
+[policy_definition]
+p = sub, obj, act, eft
+
+[policy_effect]
+e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
+
+[matchers]
+m = keyMatch(r.sub, p.sub) && keyMatch(r.obj, p.obj) && keyMatch(r.act, p.act)
+`
+
+// The objects a request is checked against. A model and a provider are named
+// as they are elsewhere in Gateway; a tool is named by the group it falls in,
+// see permission_tools.go.
+const (
+       objectModel    = "model:"
+       objectProvider = "provider:"
+       objectTool     = "tool:"
+)
+
+// actUse is the only action there is: everything a request asks for, it asks 
to
+// use. The column is kept because a hand-written rule may want another one.
+const actUse = "use"
+
+// The two casbin effects, as they are written in a policy line.
+const (
+       effectAllow = "allow"
+       effectDeny  = "deny"
+)
+
+// AgentGuard decides one agent's requests. It is nil for an agent whose
+// permissions are off, which is the case until someone turns them on.
+type AgentGuard struct {
+       agentId  string
+       enforcer *casbin.Enforcer
+}
+
+var (
+       guardLock  sync.Mutex
+       guardCache = map[string]*AgentGuard{}
+)
+
+// LoadAgentGuard builds, or reuses, the enforcer of one agent. It answers nil
+// when the agent is unrestricted, so a caller only has to check for that.
+func LoadAgentGuard(agentId string) (*AgentGuard, error) {
+       permission, err := GetAgentPermission(agentId)
+       if err != nil {
+               return nil, err
+       }
+       if !permission.Enabled {
+               return nil, nil
+       }
+
+       guardLock.Lock()
+       defer guardLock.Unlock()
+
+       if guard := guardCache[agentId]; guard != nil {
+               return guard, nil
+       }
+
+       enforcer, err := newPermissionEnforcer(permission)
+       if err != nil {
+               return nil, err
+       }
+
+       guard := &AgentGuard{agentId: agentId, enforcer: enforcer}
+       guardCache[agentId] = guard
+       return guard, nil
+}
+
+// dropAgentEnforcer forgets one agent's enforcer, so the next request is
+// decided by the permissions that were just saved.
+func dropAgentEnforcer(agentId string) {
+       guardLock.Lock()
+       defer guardLock.Unlock()
+       delete(guardCache, agentId)
+}
+
+// newPermissionEnforcer builds the casbin enforcer of one agent's policy. The
+// policy is held in memory: the rows of AgentPermission are what is persisted,
+// and every line below is compiled from them.
+func newPermissionEnforcer(permission *AgentPermission) (*casbin.Enforcer, 
error) {
+       permissionModel, err := model.NewModelFromString(PermissionModelText)
+       if err != nil {
+               return nil, err
+       }
+
+       enforcer, err := casbin.NewEnforcer(permissionModel)
+       if err != nil {
+               return nil, err
+       }
+
+       if _, err = enforcer.AddPolicies(permission.PolicyRules()); err != nil {
+               return nil, err
+       }
+       return enforcer, nil
+}
+
+// PolicyRules compiles the switches into casbin policy lines, as
+// "sub, obj, act, eft".
+func (permission *AgentPermission) PolicyRules() [][]string {
+       rules := [][]string{}
+       seen := map[string]bool{}
+       add := func(object string, effect string) {
+               rule := []string{permission.Name, object, actUse, effect}
+               key := strings.Join(rule, ",")
+               if seen[key] {
+                       return
+               }
+               seen[key] = true
+               rules = append(rules, rule)
+       }
+
+       appendList(add, objectModel, permission.ModelMode, permission.Models)
+       appendList(add, objectProvider, permission.ProviderMode, 
permission.Providers)
+
+       // Every tool is allowed except the groups that are switched off, so a 
tool
+       // the classifier does not recognize is never taken away from the agent.
+       add(objectTool+"*", effectAllow)
+       for _, group := range ToolGroups() {
+               if allowed, listed := permission.Tools[group.Name]; listed && 
!allowed {
+                       add(objectTool+group.Name, effectDeny)
+               }
+       }
+
+       for _, rule := range permission.Rules {
+               parsed, err := parsePolicyRule(rule)
+               if err != nil {
+                       continue
+               }
+               key := strings.Join(parsed, ",")
+               if seen[key] {
+                       continue
+               }
+               seen[key] = true
+               rules = append(rules, parsed)
+       }
+
+       return rules
+}
+
+// appendList writes the lines of one "all / only these / all but these" 
choice.
+func appendList(add func(object string, effect string), prefix string, mode 
string, values []string) {
+       switch mode {
+       case ListAllow:
+               // An empty allow-list allows nothing, which is what picking 
that mode
+               // and listing nothing asks for.
+               for _, value := range values {
+                       add(prefix+value, effectAllow)
+               }
+       case ListDeny:
+               add(prefix+"*", effectAllow)
+               for _, value := range values {
+                       add(prefix+value, effectDeny)
+               }
+       default:
+               add(prefix+"*", effectAllow)
+       }
+}
+
+// parsePolicyRule reads a hand-written policy line. The leading "p," of a line
+// copied out of a policy file is optional, and so is the effect, which is
+// "allow" when it is left out.
+func parsePolicyRule(rule string) ([]string, error) {
+       fields := []string{}
+       for _, field := range strings.Split(rule, ",") {
+               fields = append(fields, strings.TrimSpace(field))
+       }
+       if len(fields) > 0 && fields[0] == "p" {
+               fields = fields[1:]
+       }
+
+       if len(fields) == 3 {
+               fields = append(fields, effectAllow)
+       }
+       if len(fields) != 4 {
+               return nil, fmt.Errorf("a rule takes \"sub, obj, act\" or 
\"sub, obj, act, eft\": %s", rule)
+       }
+       for _, field := range fields {
+               if field == "" {
+                       return nil, fmt.Errorf("a rule has an empty field: %s", 
rule)
+               }
+       }
+       if fields[3] != effectAllow && fields[3] != effectDeny {
+               return nil, fmt.Errorf("the effect of a rule is \"allow\" or 
\"deny\": %s", rule)
+       }
+       return fields, nil
+}
+
+// PolicyText is the policy as it would be written in a casbin policy file. It
+// is what the advanced view shows, so nothing is enforced that cannot be read
+// there.
+func (permission *AgentPermission) PolicyText() []string {
+       lines := []string{}
+       for _, rule := range permission.PolicyRules() {
+               lines = append(lines, "p, "+strings.Join(rule, ", "))
+       }
+       return lines
+}
+
+// allow asks casbin. An enforcer that errors denies: a rule that cannot be
+// evaluated is not one to relay a request on.
+func (guard *AgentGuard) allow(object string) bool {
+       allowed, err := guard.enforcer.Enforce(guard.agentId, object, actUse)
+       if err != nil {
+               return false
+       }
+       return allowed
+}
+
+// AllowModel reports whether this agent may ask for the model it named.
+func (guard *AgentGuard) AllowModel(model string) bool {
+       return guard.allow(objectModel + model)
+}
+
+// AllowProvider reports whether this agent's requests may be sent to one
+// provider, named by its "owner/name" id.
+func (guard *AgentGuard) AllowProvider(providerId string) bool {
+       return guard.allow(objectProvider + providerId)
+}
+
+// AllowTool reports whether this agent may be offered one tool, by name.
+func (guard *AgentGuard) AllowTool(name string) bool {
+       group := ToolGroupOf(name)
+       if group == "" {
+               return true
+       }
+       return guard.allow(objectTool + group)
+}
+
+// FilterProviders drops the providers this agent may not be sent to, and says
+// which was the last one dropped so that an empty chain can report why.
+func (guard *AgentGuard) FilterProviders(providers []*Provider) ([]*Provider, 
string) {
+       allowed := []*Provider{}
+       denied := ""
+       for _, provider := range providers {
+               if !guard.AllowProvider(provider.GetId()) {
+                       denied = provider.GetId()
+                       continue
+               }
+               allowed = append(allowed, provider)
+       }
+
+       reason := ""
+       if len(allowed) == 0 && denied != "" {
+               reason = fmt.Sprintf("the permissions of agent %s do not allow 
the provider %s", guard.agentId, denied)
+       }
+       return allowed, reason
+}
diff --git a/object/permission_tools.go b/object/permission_tools.go
new file mode 100644
index 0000000..6e8fe52
--- /dev/null
+++ b/object/permission_tools.go
@@ -0,0 +1,287 @@
+// 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 sorts the tools an agent offers its model into the handful of
+// groups worth a switch, and takes the ones a switch is off for back out of 
the
+// request. A tool the model is never offered is one the agent cannot run.
+
+package object
+
+import (
+       "encoding/json"
+       "strings"
+)
+
+// The tool groups. Each is one switch on the Permissions card and one casbin
+// object, "tool:<group>".
+const (
+       ToolShell     = "shell"
+       ToolFileRead  = "fileRead"
+       ToolFileWrite = "fileWrite"
+       ToolNetwork   = "network"
+       ToolMcp       = "mcp"
+)
+
+// ToolGroup is one group as the web UI is told about it: the name its switch
+// saves, and a few of the tools that fall in it, since every agent names its
+// own differently.
+type ToolGroup struct {
+       Name     string   `json:"name"`
+       Examples []string `json:"examples"`
+}
+
+// toolGroups is the order the groups are shown and matched in. Network comes
+// before the file groups so that a "web_search" is not read as a search of the
+// disk, and the file groups before shell so that "apply_patch" is a write.
+var toolGroups = []ToolGroup{
+       {Name: ToolShell, Examples: []string{"Bash", "shell", 
"run_shell_command"}},
+       {Name: ToolFileRead, Examples: []string{"Read", "Grep", "read_file"}},
+       {Name: ToolFileWrite, Examples: []string{"Write", "Edit", 
"apply_patch"}},
+       {Name: ToolNetwork, Examples: []string{"WebFetch", "WebSearch", 
"web_fetch"}},
+       {Name: ToolMcp, Examples: []string{"mcp__*"}},
+}
+
+// ToolGroups lists the groups, for the UI that draws a switch per group.
+func ToolGroups() []ToolGroup {
+       groups := make([]ToolGroup, len(toolGroups))
+       copy(groups, toolGroups)
+       return groups
+}
+
+// toolNames are the tools of the agents Gateway knows, named as they name 
them.
+// They are matched before the keywords below, which is what keeps a 
"TodoWrite"
+// out of the group that writes files.
+var toolNames = map[string]string{
+       "bash":              ToolShell,
+       "bashoutput":        ToolShell,
+       "killbash":          ToolShell,
+       "killshell":         ToolShell,
+       "shell":             ToolShell,
+       "localshell":        ToolShell,
+       "runshellcommand":   ToolShell,
+       "runterminalcmd":    ToolShell,
+       "executecommand":    ToolShell,
+       "runcommand":        ToolShell,
+       "terminal":          ToolShell,
+       "read":              ToolFileRead,
+       "readfile":          ToolFileRead,
+       "readmanyfiles":     ToolFileRead,
+       "view":              ToolFileRead,
+       "glob":              ToolFileRead,
+       "grep":              ToolFileRead,
+       "ls":                ToolFileRead,
+       "listdir":           ToolFileRead,
+       "listdirectory":     ToolFileRead,
+       "searchfilecontent": ToolFileRead,
+       "codebasesearch":    ToolFileRead,
+       "write":             ToolFileWrite,
+       "writefile":         ToolFileWrite,
+       "edit":              ToolFileWrite,
+       "editfile":          ToolFileWrite,
+       "multiedit":         ToolFileWrite,
+       "notebookedit":      ToolFileWrite,
+       "applypatch":        ToolFileWrite,
+       "patch":             ToolFileWrite,
+       "replace":           ToolFileWrite,
+       "strreplaceeditor":  ToolFileWrite,
+       "createfile":        ToolFileWrite,
+       "deletefile":        ToolFileWrite,
+       "webfetch":          ToolNetwork,
+       "websearch":         ToolNetwork,
+       "webfetchtool":      ToolNetwork,
+       "googlewebsearch":   ToolNetwork,
+       "fetch":             ToolNetwork,
+       // The planning and delegation tools of the agents, named here so the
+       // keywords below do not read them as something they take from the disk.
+       "todowrite":    "",
+       "todoread":     "",
+       "updateplan":   "",
+       "exitplanmode": "",
+       "task":         "",
+       "agent":        "",
+}
+
+// toolKeywords catch the tools of an agent Gateway has never seen, in the 
order
+// they are tried.
+var toolKeywords = []struct {
+       group    string
+       keywords []string
+}{
+       {ToolNetwork, []string{"web", "http", "url", "browser", "curl", 
"download", "internet", "google", "fetch"}},
+       {ToolShell, []string{"bash", "shell", "terminal", "command", "cmd", 
"exec", "process", "script"}},
+       {ToolFileWrite, []string{"write", "edit", "patch", "create", "delete", 
"remove", "move", "rename", "insert", "replace", "mkdir"}},
+       {ToolFileRead, []string{"read", "view", "cat", "open", "glob", "grep", 
"search", "find", "list", "dir", "tree"}},
+}
+
+// ToolGroupOf is the group one tool falls in, or "" for a tool that fits none
+// of them and is therefore always allowed.
+func ToolGroupOf(name string) string {
+       lower := strings.ToLower(strings.TrimSpace(name))
+       if lower == "" {
+               return ""
+       }
+
+       // An MCP tool is named after the server it came from, whichever agent 
is
+       // asking: it is the one group a name says outright.
+       if strings.HasPrefix(lower, "mcp__") || strings.HasPrefix(lower, 
"mcp_") || strings.HasPrefix(lower, "mcp.") {
+               return ToolMcp
+       }
+
+       normalized := normalizeToolName(lower)
+       if group, listed := toolNames[normalized]; listed {
+               return group
+       }
+
+       for _, entry := range toolKeywords {
+               for _, keyword := range entry.keywords {
+                       if strings.Contains(normalized, keyword) {
+                               return entry.group
+                       }
+               }
+       }
+       return ""
+}
+
+// normalizeToolName drops what only separates the words of a tool's name, so
+// "run_shell_command" and "runShellCommand" are the same tool.
+func normalizeToolName(lower string) string {
+       builder := strings.Builder{}
+       for _, char := range lower {
+               if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') 
{
+                       builder.WriteRune(char)
+               }
+       }
+       return builder.String()
+}
+
+// FilterTools takes the tools of the groups this agent may not use back out of
+// a request body, and reports the ones it removed. The body is left untouched
+// when there is nothing to remove, so a request that changes nothing is 
relayed
+// byte for byte as before.
+//
+// Only the "tools" array is read, which every format the gateway speaks 
carries
+// under that name: OpenAI names a tool in "function", Anthropic and the
+// Responses API at the top level, and Gemini inside "functionDeclarations".
+func (guard *AgentGuard) FilterTools(body []byte) ([]byte, []string) {
+       fields := map[string]json.RawMessage{}
+       if err := json.Unmarshal(body, &fields); err != nil {
+               return body, nil
+       }
+
+       items := []json.RawMessage{}
+       if err := json.Unmarshal(fields["tools"], &items); err != nil || 
len(items) == 0 {
+               return body, nil
+       }
+
+       kept := []json.RawMessage{}
+       removed := []string{}
+       for _, item := range items {
+               filtered, names := guard.filterTool(item)
+               removed = append(removed, names...)
+               if filtered != nil {
+                       kept = append(kept, filtered)
+               }
+       }
+
+       if len(removed) == 0 {
+               return body, nil
+       }
+
+       if len(kept) == 0 {
+               // An empty tools array is rejected by some APIs, so a request 
left with
+               // no tool at all is sent as one that never asked for any.
+               delete(fields, "tools")
+               delete(fields, "tool_choice")
+       } else {
+               encoded, err := json.Marshal(kept)
+               if err != nil {
+                       return body, nil
+               }
+               fields["tools"] = encoded
+       }
+
+       filtered, err := json.Marshal(fields)
+       if err != nil {
+               return body, nil
+       }
+       return filtered, removed
+}
+
+// filterTool answers with the entry to keep, nil to drop it altogether, and 
the
+// names of whatever it removed.
+func (guard *AgentGuard) filterTool(item json.RawMessage) (json.RawMessage, 
[]string) {
+       entry := struct {
+               Name     string `json:"name"`
+               Type     string `json:"type"`
+               Function *struct {
+                       Name string `json:"name"`
+               } `json:"function"`
+               FunctionDeclarations []json.RawMessage 
`json:"functionDeclarations"`
+       }{}
+       if err := json.Unmarshal(item, &entry); err != nil {
+               return item, nil
+       }
+
+       // Gemini wraps its tools in one entry, so the entry stays as long as 
any of
+       // the declarations inside it does.
+       if entry.FunctionDeclarations != nil {
+               kept := []json.RawMessage{}
+               removed := []string{}
+               for _, declaration := range entry.FunctionDeclarations {
+                       name := struct {
+                               Name string `json:"name"`
+                       }{}
+                       if err := json.Unmarshal(declaration, &name); err != 
nil || guard.AllowTool(name.Name) {
+                               kept = append(kept, declaration)
+                               continue
+                       }
+                       removed = append(removed, name.Name)
+               }
+               if len(removed) == 0 {
+                       return item, nil
+               }
+               if len(kept) == 0 {
+                       return nil, removed
+               }
+
+               fields := map[string]json.RawMessage{}
+               if err := json.Unmarshal(item, &fields); err != nil {
+                       return item, nil
+               }
+               encoded, err := json.Marshal(kept)
+               if err != nil {
+                       return item, nil
+               }
+               fields["functionDeclarations"] = encoded
+               rewritten, err := json.Marshal(fields)
+               if err != nil {
+                       return item, nil
+               }
+               return rewritten, removed
+       }
+
+       name := entry.Name
+       if name == "" && entry.Function != nil {
+               name = entry.Function.Name
+       }
+       if name == "" {
+               // A built-in tool of the API itself, such as OpenAI's web 
search, is
+               // named by its type alone.
+               name = entry.Type
+       }
+       if name == "" || guard.AllowTool(name) {
+               return item, nil
+       }
+       return nil, []string{name}
+}
diff --git a/object/snapshot.go b/object/snapshot.go
index 4e7ff35..bc84a28 100644
--- a/object/snapshot.go
+++ b/object/snapshot.go
@@ -103,8 +103,11 @@ type Snapshot struct {
        Providers      []*Provider      `json:"providers,omitempty"`
        Agents         []*Agent         `json:"agents,omitempty"`
        AgentInstances []*AgentInstance `json:"agentInstances,omitempty"`
-       ProbeCases     []*ProbeCase     `json:"probeCases,omitempty"`
-       LlmPrices      []*LlmPriceEntry `json:"llmPrices,omitempty"`
+       // AgentPermissions travel with the agents: what an agent is allowed to 
ask
+       // for is part of how this machine is set up, not of what happened on 
it.
+       AgentPermissions []*AgentPermission `json:"agentPermissions,omitempty"`
+       ProbeCases       []*ProbeCase       `json:"probeCases,omitempty"`
+       LlmPrices        []*LlmPriceEntry   `json:"llmPrices,omitempty"`
 }
 
 // SnapshotCounts is how much a snapshot holds, which is what a listing shows
@@ -213,6 +216,17 @@ func BuildSnapshot(scope SnapshotScope, reason string) 
(*Snapshot, error) {
                        return nil, err
                }
                snapshot.AgentInstances = instances
+
+               permissions, err := GetAgentPermissions()
+               if err != nil {
+                       return nil, err
+               }
+               for _, permission := range permissions {
+                       snapshot.AgentPermissions = 
append(snapshot.AgentPermissions, permission)
+               }
+               sort.Slice(snapshot.AgentPermissions, func(i, j int) bool {
+                       return snapshot.AgentPermissions[i].Name < 
snapshot.AgentPermissions[j].Name
+               })
        }
 
        if scope.ProbeCases {
@@ -442,6 +456,10 @@ func importAgents(snapshot *Snapshot, mode string, dryRun 
bool, report *ImportRe
                return err
        }
 
+       if err := importAgentPermissions(snapshot, mode, dryRun, report); err 
!= nil {
+               return err
+       }
+
        if mode != ImportReplace {
                return nil
        }
@@ -463,6 +481,66 @@ func importAgents(snapshot *Snapshot, mode string, dryRun 
bool, report *ImportRe
        return nil
 }
 
+// importAgentPermissions restores what each agent is allowed to ask for. A
+// permission names nothing outside Gateway, so it is restored as it was taken.
+func importAgentPermissions(snapshot *Snapshot, mode string, dryRun bool, 
report *ImportReport) error {
+       stored, err := GetAgentPermissions()
+       if err != nil {
+               return err
+       }
+
+       named := map[string]bool{}
+       for _, incoming := range snapshot.AgentPermissions {
+               permission := *incoming
+               named[permission.Name] = true
+               id := permission.Name + " (permissions)"
+
+               _, found := stored[permission.Name]
+               if found && mode == ImportMerge {
+                       report.record(SectionAgents, id, ChangeSkip, "this 
agent already has permissions")
+                       continue
+               }
+
+               action := ChangeAdd
+               if found {
+                       action = ChangeUpdate
+               }
+               if dryRun {
+                       report.record(SectionAgents, id, action, "")
+                       continue
+               }
+
+               if err := UpdateAgentPermission(permission.Name, &permission); 
err != nil {
+                       report.record(SectionAgents, id, ChangeFail, 
err.Error())
+                       continue
+               }
+               report.record(SectionAgents, id, action, "")
+       }
+
+       if mode != ImportReplace {
+               return nil
+       }
+
+       // What is left over is put back to unrestricted, which is what an agent
+       // nobody has configured is held to.
+       for name := range stored {
+               if named[name] {
+                       continue
+               }
+               id := name + " (permissions)"
+               if dryRun {
+                       report.record(SectionAgents, id, ChangeDelete, "")
+                       continue
+               }
+               if err := UpdateAgentPermission(name, 
DefaultAgentPermission(name)); err != nil {
+                       report.record(SectionAgents, id, ChangeFail, 
err.Error())
+                       continue
+               }
+               report.record(SectionAgents, id, ChangeDelete, "")
+       }
+       return nil
+}
+
 // importAgentInstances restores the extra copies of an agent. An instance 
names
 // a state directory on the machine it was created on, so one imported from
 // elsewhere is a row that only makes sense once that agent is installed here.
diff --git a/routers/router.go b/routers/router.go
index f2498f6..fa24283 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -61,6 +61,8 @@ func initAPI() {
        beego.Router("/api/patch-agent", &controllers.ApiController{}, 
"POST:PatchAgent")
        beego.Router("/api/unpatch-agent", &controllers.ApiController{}, 
"POST:UnpatchAgent")
        beego.Router("/api/update-agent-routing", &controllers.ApiController{}, 
"POST:UpdateAgentRouting")
+       beego.Router("/api/get-agent-permission", &controllers.ApiController{}, 
"GET:GetAgentPermission")
+       beego.Router("/api/update-agent-permission", 
&controllers.ApiController{}, "POST:UpdateAgentPermission")
        beego.Router("/api/get-agent-processes", &controllers.ApiController{}, 
"GET:GetAgentProcesses")
        beego.Router("/api/start-agent", &controllers.ApiController{}, 
"POST:StartAgent")
        beego.Router("/api/stop-agent", &controllers.ApiController{}, 
"POST:StopAgent")
diff --git a/web/src/backend/PermissionBackend.ts 
b/web/src/backend/PermissionBackend.ts
new file mode 100644
index 0000000..8768063
--- /dev/null
+++ b/web/src/backend/PermissionBackend.ts
@@ -0,0 +1,29 @@
+// 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.
+
+import {query, request} from "@/backend/request";
+import type {AgentPermission, AgentPermissionInfo} from "@/types";
+
+/** What one agent may ask for. An agent nobody has configured answers with the
+ *  unrestricted default rather than with nothing. */
+export function getAgentPermission(agentId: string) {
+  return 
request<AgentPermissionInfo>(`/api/get-agent-permission${query({agentId: 
agentId})}`);
+}
+
+export function updateAgentPermission(agentId: string, permission: 
AgentPermission) {
+  return request<AgentPermissionInfo>("/api/update-agent-permission", "POST", {
+    agentId: agentId,
+    permission: permission,
+  });
+}
diff --git a/web/src/components/PermissionCard.tsx 
b/web/src/components/PermissionCard.tsx
new file mode 100644
index 0000000..f0886fa
--- /dev/null
+++ b/web/src/components/PermissionCard.tsx
@@ -0,0 +1,348 @@
+// 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.
+
+import * as React from "react";
+import {Copy, FileCode2, FolderOpen, Globe, Pencil, Plug, ShieldCheck, 
Terminal} from "lucide-react";
+import copy from "copy-to-clipboard";
+import i18next from "i18next";
+
+import * as PermissionBackend from "@/backend/PermissionBackend";
+import * as Setting from "@/Setting";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+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 {directMode} from "@/lib/agents";
+import {providerIdOf} from "@/lib/providers";
+import {cn} from "@/lib/utils";
+import type {Agent, AgentPermission, AgentPermissionInfo, Provider, ToolGroup} 
from "@/types";
+
+/** The label and the icon of each tool group, by the name the server gives it.
+ *  A group added to a later server than this page is drawn by its own name. */
+const groupLabels: {[group: string]: {label: string; icon: React.ElementType}} 
= {
+  shell: {label: "agent:Run commands", icon: Terminal},
+  fileRead: {label: "agent:Read files", icon: FolderOpen},
+  fileWrite: {label: "agent:Change files", icon: Pencil},
+  network: {label: "agent:Reach the internet", icon: Globe},
+  mcp: {label: "agent:Use MCP servers", icon: Plug},
+};
+
+/** The three ways a list of models or providers is read. */
+const listModes = ["all", "allow", "deny"];
+
+function modeOptions(anyLabel: string) {
+  return [
+    {label: i18next.t(anyLabel), value: "all"},
+    {label: i18next.t("agent:Only the ones I pick"), value: "allow"},
+    {label: i18next.t("agent:All but the ones I pick"), value: "deny"},
+  ];
+}
+
+/** One tool group, as the switch that turns it off. */
+function ToolSwitch({
+  group,
+  allowed,
+  busy,
+  onChange,
+}: {
+  group: ToolGroup;
+  allowed: boolean;
+  busy: boolean;
+  onChange: (allowed: boolean) => void;
+}) {
+  const known = groupLabels[group.name];
+  const Icon = known ? known.icon : ShieldCheck;
+
+  return (
+    <label className="flex items-start gap-2.5 rounded-md border p-2.5 
text-sm">
+      <Switch className="mt-0.5" checked={allowed} disabled={busy} 
onCheckedChange={onChange} />
+      <span className="min-w-0">
+        <span className="flex items-center gap-1.5">
+          <Icon className="h-3.5 w-3.5 text-muted-foreground" />
+          {known ? i18next.t(known.label) : group.name}
+        </span>
+        <code className="block truncate text-xs text-muted-foreground">
+          {(group.examples ?? []).join(", ")}
+        </code>
+      </span>
+    </label>
+  );
+}
+
+/** The casbin model and policy the switches above compile to, which is what is
+ *  actually enforced, plus the rules that are only written here. */
+function AdvancedView({
+  info,
+  busy,
+  onRules,
+}: {
+  info: AgentPermissionInfo;
+  busy: boolean;
+  onRules: (rules: string[]) => void;
+}) {
+  const policy = info.policy ?? [];
+  const stored = (info.permission.rules ?? []).join("\n");
+  const [draft, setDraft] = React.useState(stored);
+
+  // The textarea is only a draft until it loses focus, so a half-typed rule is
+  // never saved; a rule saved elsewhere replaces it.
+  React.useEffect(() => setDraft(stored), [stored]);
+
+  return (
+    <div className="space-y-3 rounded-md border p-3">
+      <div className="flex flex-wrap items-center justify-between gap-2">
+        <span className="flex items-center gap-1.5 text-sm font-medium">
+          <FileCode2 className="h-4 w-4 text-muted-foreground" />
+          {i18next.t("agent:Casbin configuration")}
+        </span>
+        <Button
+          variant="outline"
+          size="xs"
+          onClick={() => {
+            copy(`${info.model}\n${policy.join("\n")}\n`);
+            Setting.showMessage("success", i18next.t("agent:Casbin 
configuration copied"));
+          }}
+        >
+          <Copy />
+          {i18next.t("general:Copy")}
+        </Button>
+      </div>
+
+      <p className="text-sm text-muted-foreground">{i18next.t("agent:Casbin 
hint")}</p>
+
+      <div className="space-y-1">
+        <div className="text-xs text-muted-foreground">model.conf</div>
+        <pre className="overflow-x-auto rounded-md bg-muted p-2 
text-xs">{info.model}</pre>
+      </div>
+
+      <div className="space-y-1">
+        <div className="text-xs text-muted-foreground">policy.csv</div>
+        <pre className="overflow-x-auto rounded-md bg-muted p-2 text-xs">
+          {policy.length === 0 ? "# " + i18next.t("agent:No rule yet") : 
policy.join("\n")}
+        </pre>
+      </div>
+
+      <div className="space-y-1">
+        <div className="text-xs text-muted-foreground">{i18next.t("agent:Extra 
rules")}</div>
+        <Textarea
+          className="font-mono text-xs"
+          rows={3}
+          spellCheck={false}
+          value={draft}
+          disabled={busy}
+          placeholder="claude-code, tool:mcp, use, deny"
+          onChange={event => setDraft(event.target.value)}
+          onBlur={() => {
+            const rules = draft.split("\n").map(line => 
line.trim()).filter(line => line !== "");
+            if (rules.join("\n") !== stored) {
+              onRules(rules);
+            }
+          }}
+        />
+        <p className="text-xs text-muted-foreground">{i18next.t("agent:Extra 
rules hint")}</p>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * What one agent is allowed to ask Gateway for: the tools it may be offered,
+ * the models it may name and the providers its requests may reach. The 
switches
+ * are what is set; casbin is what decides, and the advanced view shows the
+ * policy they compile to.
+ */
+export function PermissionCard({agent, providers}: {agent: Agent; providers: 
Provider[]}) {
+  const [info, setInfo] = React.useState<AgentPermissionInfo | null>(null);
+  const [busy, setBusy] = React.useState(false);
+  const [advanced, setAdvanced] = React.useState(false);
+
+  const agentId = agent.agentId;
+
+  React.useEffect(() => {
+    if (agentId === "") {
+      return;
+    }
+    PermissionBackend.getAgentPermission(agentId)
+      .then(res => setInfo(res.status === "ok" ? (res.data ?? null) : null))
+      .catch(() => setInfo(null));
+  }, [agentId]);
+
+  if (info === null) {
+    return null;
+  }
+
+  const permission = info.permission;
+  const groups = info.groups ?? [];
+  const blocked = groups.filter(group => permission.tools?.[group.name] === 
false);
+
+  const save = (changed: Partial<AgentPermission>) => {
+    const next = {...permission, ...changed};
+    setBusy(true);
+    PermissionBackend.updateAgentPermission(agentId, next)
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setInfo(res.data);
+          Setting.showMessage("success", i18next.t("agent:Permissions saved"));
+        } else {
+          Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
save the permissions"));
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setBusy(false));
+  };
+
+  const setTool = (group: string, allowed: boolean) =>
+    save({tools: {...(permission.tools ?? {}), [group]: allowed}});
+
+  // The models of every provider, so the field offers what this machine can
+  // actually reach rather than an empty box.
+  const modelSuggestions = Array.from(
+    new Set(providers.flatMap(provider => provider.models ?? [])),
+  ).sort();
+
+  const listMode = (mode: string) => (listModes.includes(mode) ? mode : "all");
+
+  return (
+    <Card>
+      <CardHeader className="p-4 pb-2">
+        <div className="flex flex-wrap items-center justify-between gap-2">
+          <CardTitle className="flex items-center gap-2 text-base">
+            {i18next.t("agent:Permissions")}
+            {permission.enabled ? (
+              <Badge variant={blocked.length > 0 ? "success" : "muted"}>
+                {i18next.t("agent:Enforced")}
+              </Badge>
+            ) : (
+              <Badge variant="muted">{i18next.t("agent:Unrestricted")}</Badge>
+            )}
+          </CardTitle>
+          <Button variant="ghost" size="xs" onClick={() => 
setAdvanced(!advanced)}>
+            {i18next.t("general:Advanced")}
+          </Button>
+        </div>
+      </CardHeader>
+
+      <CardContent className="space-y-3 p-4 pt-0">
+        <label className="flex items-start gap-2 text-sm">
+          <Switch
+            className="mt-0.5"
+            checked={permission.enabled}
+            disabled={busy}
+            onCheckedChange={checked => save({enabled: checked})}
+          />
+          <span>
+            {i18next.t("agent:Enforce permissions")}
+            <span className="block text-muted-foreground">
+              {i18next.t(permission.enabled ? "agent:Enforced hint" : 
"agent:Unrestricted hint")}
+            </span>
+          </span>
+        </label>
+
+        {/* An agent that was written the provider's own URL never calls the
+            proxy, so there is nothing here to hold it to. */}
+        {permission.enabled && agent.mode === directMode ? (
+          <p className="text-sm text-warning">{i18next.t("agent:Direct mode 
permission hint")}</p>
+        ) : null}
+
+        <div className={cn("space-y-3", !permission.enabled && 
"pointer-events-none opacity-50")}>
+          <div className="space-y-2">
+            <div className="text-sm font-medium">{i18next.t("agent:What it may 
do")}</div>
+            <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
+              {groups.map(group => (
+                <ToolSwitch
+                  key={group.name}
+                  group={group}
+                  allowed={permission.tools?.[group.name] !== false}
+                  busy={busy || !permission.enabled}
+                  onChange={allowed => setTool(group.name, allowed)}
+                />
+              ))}
+            </div>
+            <p className="text-xs 
text-muted-foreground">{i18next.t("agent:Tool hint")}</p>
+          </div>
+
+          <div className="space-y-2">
+            <div className="text-sm 
font-medium">{i18next.t("provider:Models")}</div>
+            <SimpleSelect
+              value={listMode(permission.modelMode)}
+              options={modeOptions("agent:Any model")}
+              disabled={busy || !permission.enabled}
+              onChange={value => save({modelMode: value})}
+            />
+            {listMode(permission.modelMode) === "all" ? null : (
+              <>
+                <TagsInput
+                  value={permission.models}
+                  suggestions={modelSuggestions}
+                  disabled={busy || !permission.enabled}
+                  placeholder={i18next.t("agent:Model placeholder")}
+                  onChange={models => save({models: models})}
+                />
+                <p className="text-xs 
text-muted-foreground">{i18next.t("agent:Model list hint")}</p>
+              </>
+            )}
+          </div>
+
+          <div className="space-y-2">
+            <div className="text-sm 
font-medium">{i18next.t("provider:Providers")}</div>
+            <SimpleSelect
+              value={listMode(permission.providerMode)}
+              options={modeOptions("agent:Any provider")}
+              disabled={busy || !permission.enabled}
+              onChange={value => save({providerMode: value})}
+            />
+            {listMode(permission.providerMode) === "all" ? null : 
providers.length === 0 ? (
+              <p className="text-sm 
text-muted-foreground">{i18next.t("agent:No provider yet")}</p>
+            ) : (
+              <div className="flex flex-wrap gap-2">
+                {providers.map(provider => {
+                  const id = providerIdOf(provider);
+                  const picked = (permission.providers ?? []).includes(id);
+                  return (
+                    <button
+                      key={id}
+                      type="button"
+                      disabled={busy || !permission.enabled}
+                      onClick={() =>
+                        save({
+                          providers: picked
+                            ? (permission.providers ?? []).filter(item => item 
!== id)
+                            : [...(permission.providers ?? []), id],
+                        })
+                      }
+                      className={cn(
+                        "rounded-md border px-3 py-1.5 text-sm 
transition-colors",
+                        picked ? "border-primary bg-primary/10" : 
"hover:bg-accent",
+                        (busy || !permission.enabled) && "cursor-not-allowed 
opacity-50",
+                      )}
+                    >
+                      {provider.displayName || provider.name}
+                    </button>
+                  );
+                })}
+              </div>
+            )}
+          </div>
+        </div>
+
+        {advanced ? (
+          <AdvancedView info={info} busy={busy} onRules={rules => save({rules: 
rules})} />
+        ) : null}
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index a5cf468..282af96 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -35,8 +35,11 @@
     "Agents": "Agents",
     "Agents on this machine": "Agents on this machine",
     "All agents": "All agents",
+    "All but the ones I pick": "All but the ones I pick",
     "All event types": "All event types",
     "All outcomes": "All outcomes",
+    "Any model": "Any model",
+    "Any provider": "Any provider",
     "Auto refresh": "Auto refresh",
     "Base URL hint": "Give the agent the settings below. Requests that arrive 
at this URL always go to the provider selected above, whatever model they 
name.",
     "Bound provider cooling down": "The bound provider is out of rotation, 
requests go to the fallbacks",
@@ -46,6 +49,10 @@
     "Built-in model restored": "Put back on its own model",
     "Capture sign-in": "Capture sign-in",
     "Capture sign-in hint": "Open the next sign-in link the browser hands back 
with this copy. Without it the link opens whichever copy the agent registered 
itself for, and this one can never be signed in.",
+    "Casbin configuration": "Casbin configuration",
+    "Casbin configuration copied": "Casbin configuration copied to clipboard",
+    "Casbin hint": "The switches above compile to this policy, which is what 
casbin decides every request by.",
+    "Change files": "Change files",
     "Claude Code config hint": "The same variables can live in the \"env\" 
block of ~/.claude/settings.json, so every session picks them up without 
exporting anything.",
     "Claude Desktop sandbox hint": "Claude Desktop runs its Cowork and Code 
sessions in a sandbox of its own, where 127.0.0.1 is that sandbox rather than 
this host. The URL above names this machine on its own network instead, and 
Gateway answers there as well as on loopback.",
     "Client auth token hint": "This provider forwards the agent's own 
credentials, so no token is set: the agent keeps the sign-in it already has.",
@@ -59,15 +66,22 @@
     "Details": "Details",
     "Direct": "Direct",
     "Direct mode hint": "The provider's own base URL and key are written into 
the agent's configuration. Switching to another provider rewrites the file, and 
the agent has to be restarted to pick it up.",
+    "Direct mode permission hint": "This agent is bound straight to its 
provider, so its requests never reach Gateway and none of these rules apply. 
Switch it back to routing through Gateway.",
     "Duration": "Duration",
     "Empty message": "Empty message",
     "Enable": "Enable",
+    "Enforce permissions": "Enforce permissions",
+    "Enforced": "Enforced",
+    "Enforced hint": "Every request this agent relays through Gateway is held 
to the rules below.",
     "Event": "Event",
+    "Extra rules": "Extra rules",
+    "Extra rules hint": "One policy line per row, as \"sub, obj, act, eft\". 
They are added to the policy above, where a deny always wins.",
     "Failed to add the instance": "Failed to add the instance",
     "Failed to get agent records": "Failed to get agent records",
     "Failed to get agent sessions": "Failed to get agent sessions",
     "Failed to install the agent": "Failed to install the agent",
     "Failed to read this transcript": "Failed to read this transcript",
+    "Failed to save the permissions": "Failed to save the permissions",
     "Failed to scan agents": "Failed to scan agents",
     "Failed to start the agent": "Failed to start the agent",
     "Failed to stop the agent": "Failed to stop the agent",
@@ -100,6 +114,8 @@
     "Last {count} records": "Last {count} records",
     "MCP target": "MCP target",
     "Model": "Model",
+    "Model list hint": "A name may end in \"*\", so \"claude-opus-*\" covers 
the whole family.",
+    "Model placeholder": "Type a model name and press Enter",
     "Monitored": "Monitored",
     "Monitoring disabled": "Monitoring disabled",
     "Monitoring enabled": "Monitoring enabled",
@@ -109,6 +125,8 @@
     "No agent sessions yet - patch an agent to start collecting them": "No 
sessions yet - turn on monitoring for an agent to start collecting them",
     "No instances yet": "No instances yet - add one to sign in with a second 
account",
     "No other provider speaks this API": "No other provider speaks this API",
+    "No provider yet": "No provider has been added yet.",
+    "No rule yet": "no rule yet",
     "No supported agents found": "No supported agents found",
     "No usage yet": "No requests have been relayed for this agent yet",
     "Not installed": "Not installed",
@@ -125,11 +143,14 @@
     "Onboarding connect agent detail": "Pick that provider on one of the agent 
cards above.",
     "Onboarding see it work": "See it work",
     "Onboarding see it work detail": "Watch requests land in LLM Records once 
traffic flows through.",
+    "Only the ones I pick": "Only the ones I pick",
     "Patch": "Turn on monitoring",
     "Patch Status": "Monitoring",
     "Patched": "Monitoring",
     "Path copied to clipboard": "Path copied to clipboard",
     "Payload": "Payload",
+    "Permissions": "Permissions",
+    "Permissions saved": "Permissions saved",
     "Preview": "Preview",
     "Preview hint": "What Gateway would write, with the keys masked. 
Everything else in these files is left as it is.",
     "Processes": "Processes",
@@ -138,6 +159,8 @@
     "Provider enabled": "Provider enabled",
     "Provider saved": "Provider saved",
     "Provider speaks another API": "This agent speaks the {protocol} API, 
which the bound provider does not. A direct binding needs both to speak the 
same one: pick another provider, or route this agent through the gateway, which 
translates.",
+    "Reach the internet": "Reach the internet",
+    "Read files": "Read files",
     "Read from the agent's own logs": "Read from the agent's own logs",
     "Records": "Records",
     "Records are kept in memory only and are lost when Gateway restarts": 
"Records are kept in memory only and are lost when Gateway restarts",
@@ -151,6 +174,7 @@
     "Rewrite configuration": "Rewrite configuration",
     "Route through Gateway": "Route through Gateway",
     "Run Status": "Run Status",
+    "Run commands": "Run commands",
     "Running": "Running",
     "Running in a container": "Gateway is running in a container",
     "Running in a container detail": "Agents are found by reading the home 
directories of the machine Gateway runs on, and in a container that is the 
container's own filesystem rather than the host's. Install Gateway on the host 
to see the agents installed there. Providers and LLM Records work either way.",
@@ -175,22 +199,27 @@
     "Tool": "Tool",
     "Tool call": "Tool call",
     "Tool error": "Tool error",
+    "Tool hint": "A tool switched off is taken out of the request before the 
model ever sees it, so the agent never gets to call it.",
     "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",
     "Turn on monitoring to collect activity": "Turn on monitoring to collect 
activity",
     "Unknown": "Unknown",
     "Unpatch": "Turn off monitoring",
+    "Unrestricted": "Unrestricted",
+    "Unrestricted hint": "This agent may ask for any model, reach any provider 
and use every tool.",
     "Upgrade": "Upgrade",
     "Upgrade hint": "Replaces this installation with the latest version, 
through the package manager that installed it. Restart the agent afterwards to 
run the new one.",
     "Upgrade {agent}?": "Upgrade {agent}?",
     "Upgrading": "Upgrading",
     "Usage": "Usage",
+    "Use MCP servers": "Use MCP servers",
     "User": "User",
     "Version": "Version",
     "View Records": "View Records",
     "View all": "View all",
     "Waiting for the sign-in link": "Waiting for the sign-in link",
+    "What it may do": "What it may do",
     "Write configuration": "Write configuration",
     "Write confirm hint": "Gateway writes the selected provider into this 
agent's own configuration file, replacing the file in one step. Restart any 
session that is already running.",
     "Write the configuration of {agent}?": "Write the configuration of 
{agent}?",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 08d9a71..f7c18d8 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -35,8 +35,11 @@
     "Agents": "智能体",
     "Agents on this machine": "本机的智能体",
     "All agents": "全部 Agent",
+    "All but the ones I pick": "除勾选的以外都允许",
     "All event types": "全部事件类型",
     "All outcomes": "全部结果",
+    "Any model": "任意模型",
+    "Any provider": "任意供应商",
     "Auto refresh": "自动刷新",
     "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的供应商,与请求里的模型名无关。",
     "Bound provider cooling down": "绑定的供应商已暂时移出轮转,请求将走备用供应商",
@@ -46,6 +49,10 @@
     "Built-in model restored": "已恢复为自带模型",
     "Capture sign-in": "接管登录",
     "Capture sign-in hint": "让浏览器返回的下一个登录链接打开这个副本。否则链接只会打开 Agent 
自己注册的那个副本,这个副本永远登录不上。",
+    "Casbin configuration": "Casbin 配置",
+    "Casbin configuration copied": "Casbin 配置已复制到剪贴板",
+    "Casbin hint": "上面的开关会编译成这份策略,网关的每个请求都由 casbin 按它来判定。",
+    "Change files": "修改文件",
     "Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env 
段,这样不用每次 export,所有会话都会生效。",
     "Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code 
会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway 
除回环外也会在该地址上监听。",
     "Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent 
继续用它已有的登录。",
@@ -59,15 +66,22 @@
     "Details": "详情",
     "Direct": "直连",
     "Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启 
Agent 才能生效。",
+    "Direct mode permission hint": "该智能体直连供应商,请求不经过网关,这里的规则不会生效。请把它切回经网关转发。",
     "Duration": "耗时",
     "Empty message": "空消息",
     "Enable": "启用",
+    "Enforce permissions": "启用权限控制",
+    "Enforced": "已启用",
+    "Enforced hint": "该智能体经网关转发的每个请求,都会按下面的规则检查。",
     "Event": "事件",
+    "Extra rules": "额外规则",
+    "Extra rules hint": "每行一条策略,格式为 \"sub, obj, act, eft\"。它们会追加到上面的策略中,deny 
优先。",
     "Failed to add the instance": "添加实例失败",
     "Failed to get agent records": "获取 Agent 记录失败",
     "Failed to get agent sessions": "获取 Agent 会话失败",
     "Failed to install the agent": "安装失败",
     "Failed to read this transcript": "读取该会话记录失败",
+    "Failed to save the permissions": "保存权限失败",
     "Failed to scan agents": "探测 Agent 失败",
     "Failed to start the agent": "启动 Agent 失败",
     "Failed to stop the agent": "停止 Agent 失败",
@@ -100,6 +114,8 @@
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
     "Model": "模型",
+    "Model list hint": "名称可以用 \"*\" 结尾,例如 \"claude-opus-*\" 覆盖整个系列。",
+    "Model placeholder": "输入模型名后按回车",
     "Monitored": "监控采集",
     "Monitoring disabled": "已关闭监控",
     "Monitoring enabled": "已开启监控",
@@ -109,6 +125,8 @@
     "No agent sessions yet - patch an agent to start collecting them": 
"还没有会话,开启某个智能体的监控后才会开始收集",
     "No instances yet": "暂无实例 - 添加一个即可用第二个账号登录",
     "No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
+    "No provider yet": "还没有添加任何供应商。",
+    "No rule yet": "还没有任何规则",
     "No supported agents found": "没有发现本机安装的智能体",
     "No usage yet": "该 Agent 还没有经过网关的请求",
     "Not installed": "未安装",
@@ -125,11 +143,14 @@
     "Onboarding connect agent detail": "在上方的 Agent 卡片中选择该 Provider。",
     "Onboarding see it work": "查看效果",
     "Onboarding see it work detail": "流量接入后,可在 LLM Records 中看到请求记录。",
+    "Only the ones I pick": "只允许勾选的",
     "Patch": "开启监控",
     "Patch Status": "监控状态",
     "Patched": "监控中",
     "Path copied to clipboard": "路径已复制到剪贴板",
     "Payload": "负载",
+    "Permissions": "权限",
+    "Permissions saved": "权限已保存",
     "Preview": "预览",
     "Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
     "Processes": "进程",
@@ -138,6 +159,8 @@
     "Provider enabled": "已启用供应商",
     "Provider saved": "供应商已保存",
     "Provider speaks another API": "该 Agent 使用 {protocol} 
API,而绑定的供应商不支持。直连模式要求两者使用同一种 API:请改选其他供应商,或将该 Agent 改为经网关转发(网关会自动转换)。",
+    "Reach the internet": "访问网络",
+    "Read files": "读取文件",
     "Read from the agent's own logs": "读自 agent 自己的日志",
     "Records": "记录数",
     "Records are kept in memory only and are lost when Gateway restarts": 
"记录仅保存在内存中,Gateway 重启后会丢失",
@@ -151,6 +174,7 @@
     "Rewrite configuration": "重新写入配置",
     "Route through Gateway": "经由 Gateway 路由",
     "Run Status": "运行状态",
+    "Run commands": "执行命令",
     "Running": "运行中",
     "Running in a container": "Gateway 正跑在容器里",
     "Running in a container detail": "Agent 是靠读取 Gateway 
所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。Providers 
和 LLM Records 在容器里照常可用。",
@@ -175,22 +199,27 @@
     "Tool": "工具",
     "Tool call": "工具调用",
     "Tool error": "工具报错",
+    "Tool hint": "关掉的工具会在请求发给模型之前被移除,智能体拿不到,也就无法调用。",
     "Tool result": "工具返回",
     "Tool use ID": "工具调用 ID",
     "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来",
     "Turn on monitoring to collect activity": "开启监控后才会收集活动",
     "Unknown": "未知",
     "Unpatch": "关闭监控",
+    "Unrestricted": "不受限制",
+    "Unrestricted hint": "该智能体可以使用任意模型、任意供应商和全部工具。",
     "Upgrade": "升级",
     "Upgrade hint": "通过当初安装它的包管理器,将该安装替换为最新版本。升级后需重启代理才能用上新版本。",
     "Upgrade {agent}?": "升级 {agent}?",
     "Upgrading": "升级中",
     "Usage": "用量",
+    "Use MCP servers": "使用 MCP 服务",
     "User": "用户",
     "Version": "版本",
     "View Records": "查看活动",
     "View all": "查看全部",
     "Waiting for the sign-in link": "正在等待登录链接",
+    "What it may do": "它可以做什么",
     "Write configuration": "写入配置",
     "Write confirm hint": "Gateway 会把所选供应商以该 Agent 
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
     "Write the configuration of {agent}?": "写入 {agent} 的配置?",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index f4aee80..c33803f 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -28,6 +28,7 @@ import {accountLabel} from "@/components/AgentGridCard";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {InstallOutput, ToolUpgradeConfirmDialog} from 
"@/components/ToolUpgradeConfirmDialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
+import {PermissionCard} from "@/components/PermissionCard";
 import {ProviderCard} from "@/components/ProviderCard";
 import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
 import {Alert, AlertDescription} from "@/components/ui/alert";
@@ -543,6 +544,8 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
         </Card>
       </div>
 
+      <PermissionCard agent={agent} providers={providers} />
+
       <AgentInstances agent={agent} enabled={isAdmin} />
 
       {!watching ? (
diff --git a/web/src/types.ts b/web/src/types.ts
index 48b42ce..b385fc9 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -171,6 +171,43 @@ export interface Agent {
   supportsInstances?: boolean;
 }
 
+/** One group of tools the Permissions card draws a switch for. */
+export interface ToolGroup {
+  name: string;
+  /** A few of the tools that fall in the group, since every agent names its 
own
+   *  differently. */
+  examples: string[];
+}
+
+/** What one agent is allowed to ask the gateway for. Mirrors
+ *  object.AgentPermission on the server. */
+export interface AgentPermission {
+  owner: string;
+  name: string;
+  createdTime?: string;
+  updatedTime?: string;
+  /** Off leaves the agent unrestricted, which is what it is until this is on. 
*/
+  enabled: boolean;
+  /** "all", "allow" for only the listed, or "deny" for everything but them. */
+  modelMode: string;
+  models: string[];
+  providerMode: string;
+  providers: string[];
+  /** One entry per tool group, false where the group is switched off. */
+  tools: {[group: string]: boolean};
+  /** Extra casbin policy lines, written by hand on the advanced view. */
+  rules: string[];
+}
+
+/** The permissions of one agent with what the card needs to draw them: the 
tool
+ *  groups, and the casbin model and policy they compile to. */
+export interface AgentPermissionInfo {
+  permission: AgentPermission;
+  groups: ToolGroup[];
+  model: string;
+  policy: string[];
+}
+
 /**
  * One extra copy of an agent, kept apart from the others by a state directory
  * of its own, so that each is signed in to a different account and the two can
@@ -564,7 +601,18 @@ export interface LlmAuditReport {
 export type ProviderProbeMode = "auto" | "manual" | "off";
 
 /** The questions an active probe asks, none of which the records can answer. 
*/
-export type ProbeKey = "identity" | "vendor" | "stream" | "cache" | "tools" | 
"billing";
+export type ProbeKey =
+  | "identity"
+  | "vendor"
+  | "stream"
+  | "cache"
+  | "tools"
+  | "billing"
+  | "knowledge"
+  | "selfid"
+  | "hidden"
+  | "feature"
+  | "repeat";
 
 /**
  * One probe measurement. `facts` is what actually came back, as data rather
@@ -588,7 +636,7 @@ export interface ProbeCheck {
 export type ProbeGrade = "A" | "B" | "C" | "D" | "F" | "unknown";
 
 /** The knobs of the check engine a case runs on. Fields another engine owns 
are
- * ignored, so one shape edits all six. */
+ * ignored, so one shape edits every case in the suite. */
 export interface ProbeCaseParams {
   system: string;
   prompt: string;
@@ -606,6 +654,18 @@ export interface ProbeCaseParams {
   alertHigh: number;
   warnLow: number;
   alertLow: number;
+  /** The answers that pass, the ones that fail, and how they are compared. */
+  expect: string[];
+  forbid: string[];
+  match: "" | "contains" | "exact" | "regex";
+  /** JSON merged into the request body, and the answer fields it has to
+   * produce, as dotted paths with an optional "=pattern". */
+  extra: string;
+  require: string[];
+  /** How many times an identical request goes out. */
+  samples: number;
+  /** Vendors whose models this case applies to. Empty asks it of every model. 
*/
+  vendors: string[];
 }
 
 /**


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

Reply via email to