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 8e76f06 feat(permission): expand each agent's permissions into
grouped switches and a page of their own
8e76f06 is described below
commit 8e76f062df13cef9156ef10d24a4f9b16cc672c0
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 01:39:40 2026 +0800
feat(permission): expand each agent's permissions into grouped switches and
a page of their own
---
README.md | 14 +-
README_zh.md | 14 +-
controllers/permission.go | 53 ++++-
object/permission_casbin.go | 128 +++++++++--
object/permission_tools.go | 386 +++++++++++++++++++++-------------
routers/router.go | 1 +
web/src/App.tsx | 7 +
web/src/backend/PermissionBackend.ts | 19 +-
web/src/components/PermissionCard.tsx | 256 +++++++++++++++++-----
web/src/locales/en/data.json | 115 +++++++++-
web/src/locales/zh/data.json | 168 ++++++++++++++-
web/src/nav.ts | 2 +
web/src/pages/PermissionsPage.tsx | 190 +++++++++++++++++
web/src/types.ts | 20 +-
14 files changed, 1116 insertions(+), 257 deletions(-)
diff --git a/README.md b/README.md
index ba2d0dd..3c76610 100644
--- a/README.md
+++ b/README.md
@@ -89,7 +89,7 @@
- **[Switch every agent's provider from one
place](#send-an-agents-traffic-through-gateway)** — change an API key or base
URL once, and every agent pointed at Gateway picks it up.
- **[Run several instances of one agent side by side](#what-to-do-next)** —
e.g. multiple Claude Desktop instances, each signed in to a different account.
- **[See the whole request, not just a count](#recording-prompts)** — every
prompt, message and tool schema an agent sent, kept on this machine.
-- **[Say what each agent may do](#what-each-agent-is-allowed-to-do)** — a
switch per tool group, model and provider, enforced by Casbin on every request
it relays.
+- **[Say what each agent may do](#what-each-agent-is-allowed-to-do)** — around
forty switches per agent, in groups, over its tools, models and providers,
enforced by Casbin on every request it relays.
- **[Know what every agent spent, even off
Gateway](#what-the-agents-spend-including-what-never-went-through-gateway)** —
read straight from the agents' own transcripts.
- **[Compare and copy skills, MCP servers and prompts across
agents](#what-to-do-next)** — every agent's install list in one table.
@@ -203,9 +203,9 @@ Codex is the exception: its ChatGPT sign-in talks to an
endpoint no provider sta
### What each agent is allowed to do
-Everything an agent relays through Gateway is held to the rules on its own
page, so an agent can be given less than it came with without editing its own
configuration. **Agents** → open one → **Permissions**:
+**Permissions** in the sidebar is the page for it: every agent on this machine
down one side, what the one you picked may do beside it. The same card is on
the agent's own page. Everything that agent relays through Gateway is held to
what is set there, so it can be given less than it came with without editing
its own configuration.
-- **Tools** — one switch per group: running commands, reading files, changing
files, reaching the internet, and MCP servers. A tool whose switch is off is
taken out of the request before it leaves this machine, so the model is never
offered it and the agent never gets to call it. Every agent names its tools
differently, and `Bash`, `shell` and `run_shell_command` are all the same
switch.
+- **Tools** — around forty switches, in six groups: the terminal, reading the
project, changing the project, the internet, planning and delegation, and one
switch per MCP server that agent has installed. The switch on a group's header
sets the whole group at once; open the group and the answer gets as fine as you
like, since running a command, reading a running command's output and stopping
one are three switches. A tool whose switch is off is taken out of the request
before it leaves th [...]
- **Models** — any model, only the ones you pick, or all but them. A name may
end in `*`, so `claude-opus-*` covers a whole family.
- **Providers** — which of the providers this agent's requests may be sent to.
@@ -214,12 +214,16 @@ A request that asks for something switched off comes back
as a `permission_error
Underneath, the switches compile to a [Casbin](https://casbin.org) policy, and
every relayed request is decided by an enforcer rather than by a hand-written
check. **Advanced** shows the `model.conf` and `policy.csv` they compile to,
and takes extra policy lines of your own:
```
-p, claude-code, model:*, use, allow
p, claude-code, model:claude-opus-*, use, deny
+p, claude-code, model:*, use, allow
+p, claude-code, tool:shell/run, use, deny
+p, claude-code, tool:mcp/github, use, allow
+p, claude-code, tool:mcp/*, use, deny
p, claude-code, tool:*, use, allow
-p, claude-code, tool:shell, use, deny
```
+The first rule that matches decides, which is what lets one exception stand in
front of the rule behind it: every MCP server taken away except the one that
stays. The lines you write yourself are checked before the ones the switches
wrote.
+
The rules apply to what goes through the proxy, so an agent bound directly to
a provider — its own configuration pointing at the vendor rather than at
Gateway — is not held to them. The page says so where that is the case.
### What the agents spend, including what never went through Gateway
diff --git a/README_zh.md b/README_zh.md
index 13f2fcb..6cbaa57 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -89,7 +89,7 @@
- **[一个地方切换所有 Agent 的 API 供应商](#让-agent-的流量走-gateway)** —— 改一次 Key 或 base
URL,接进 Gateway 的每个 Agent 都跟着换。
- **[同一个 Agent 开多个实例](#接下来做什么)** —— 比如同时跑好几个 Claude Desktop,各自登录不同账号。
- **[看到完整的请求,而不只是一个数字](#记录提示词)** —— 每一条 prompt、消息和工具 schema,都留在这台机器上。
-- **[规定每个 Agent 能做什么](#每个-agent-能做什么)** —— 工具、模型、供应商各一排开关,每个转发的请求都由 Casbin 判定。
+- **[规定每个 Agent 能做什么](#每个-agent-能做什么)** —— 每个 Agent
四十来个开关,分组管理,覆盖工具、模型和供应商,每个转发的请求都由 Casbin 判定。
- **[统计每个 Agent 花了多少,包括没走 Gateway 的部分](#每个-agent-花了多少包括没走-gateway-的那部分)** ——
直接读 Agent 自己写的会话记录。
- **[跨 Agent 对比、复制技能 / MCP / 提示词](#接下来做什么)** —— 一张表看到所有 Agent 装了什么。
@@ -200,9 +200,9 @@ Codex 是例外:它的 ChatGPT 登录走的是没有任何 Provider 能替代
### 每个 Agent 能做什么
-经 Gateway 转发的每个请求,都按这个 Agent 自己页面上的规则来判。不用改 Agent 自己的配置,就能把它的能力收窄。**Agents** →
打开一个 → **Permissions**:
+侧边栏的 **Permissions** 就是这个页面:一侧是这台机器上的每个 Agent,另一侧是选中那个能做什么;Agent
自己的详情页上也有同一张卡片。这个 Agent 经 Gateway 转发的所有请求,都按这里设的来判,不用改它自己的配置,就能把能力收窄。
-- **工具** —— 每组一个开关:执行命令、读取文件、修改文件、访问网络、MCP
服务。关掉的工具会在请求离开这台机器之前被删掉,模型压根看不到它,Agent 也就无从调用。各家 Agent
的工具名都不一样,`Bash`、`shell`、`run_shell_command` 都归同一个开关管。
+- **工具** —— 四十来个开关,分成六组:终端、读取项目、修改项目、访问网络、规划与委派,再加上这个 Agent 装的每一个 MCP
服务各一个开关。点分组标题上的开关就能整组一起设;展开这一组,粒度想多细有多细 ——
执行命令、读取运行中命令的输出、停止命令,是三个独立开关。关掉的工具会在请求离开这台机器之前被删掉,模型压根看不到它,Agent 也就无从调用。各家
Agent 的工具名都不一样,`Bash`、`shell`、`run_shell_command` 都归同一个开关管。每组最后都有一个兜底项,管的是
Gateway 没见过的同类工具 —— 有了它,关掉一组才是真的关死,而不是只关掉设置那天恰好列出来的几个。
- **模型** —— 任意模型、只允许勾选的、或除勾选的以外都允许。名称可以用 `*` 结尾,`claude-opus-*` 就覆盖一整个系列。
- **供应商** —— 这个 Agent 的请求可以发给哪几个 Provider。
@@ -211,12 +211,16 @@ Codex 是例外:它的 ChatGPT 登录走的是没有任何 Provider 能替代
底层上,这些开关会编译成一份 [Casbin](https://casbin.org) 策略,每个转发的请求都由 enforcer 判定,而不是靠手写的
if。点 **Advanced** 就能看到它们编译出的真实 `model.conf` 和 `policy.csv`,也可以自己往里加策略行:
```
-p, claude-code, model:*, use, allow
p, claude-code, model:claude-opus-*, use, deny
+p, claude-code, model:*, use, allow
+p, claude-code, tool:shell/run, use, deny
+p, claude-code, tool:mcp/github, use, allow
+p, claude-code, tool:mcp/*, use, deny
p, claude-code, tool:*, use, allow
-p, claude-code, tool:shell, use, deny
```
+第一条匹配上的规则说了算,所以例外可以排在兜底规则前面:MCP 服务全关,只留下一个。你自己写的那几行,排在开关生成的规则之前。
+
规则管的是走代理的流量,所以直连供应商的 Agent(它自己的配置指向厂商而不是 Gateway)不受这些规则约束,页面上会直接提示。
### 每个 Agent 花了多少,包括没走 Gateway 的那部分
diff --git a/controllers/permission.go b/controllers/permission.go
index 569dd72..1ee8bfd 100644
--- a/controllers/permission.go
+++ b/controllers/permission.go
@@ -18,12 +18,13 @@ import (
"encoding/json"
"github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentconfig"
"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.
+// to draw them: the switches it has, 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"`
@@ -31,15 +32,34 @@ type agentPermissionInfo struct {
Policy []string `json:"policy"`
}
-func newAgentPermissionInfo(permission *object.AgentPermission)
*agentPermissionInfo {
+func newAgentPermissionInfo(permission *object.AgentPermission, owner string)
*agentPermissionInfo {
return &agentPermissionInfo{
Permission: permission,
- Groups: object.ToolGroups(),
+ Groups: object.ToolGroups(mcpItemsOf(permission.Name,
owner)),
Model: object.PermissionModelText,
Policy: permission.PolicyText(),
}
}
+// mcpItemsOf is one switch per MCP server the agent has installed, so a server
+// can be taken away from it by name rather than all of them at once. An agent
+// whose configuration cannot be read keeps the catch-all switch alone.
+func mcpItemsOf(agentId string, owner string) []object.ToolItem {
+ items := []object.ToolItem{}
+ if owner == "" {
+ return items
+ }
+
+ inventory := agentconfig.Read(agentId, owner)
+ if !inventory.McpSupported {
+ return items
+ }
+ for _, server := range inventory.McpServers {
+ items = append(items, object.McpServerItem(server.Name))
+ }
+ return items
+}
+
// 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.
@@ -60,7 +80,27 @@ func (c *ApiController) GetAgentPermission() {
return
}
- c.ResponseOk(newAgentPermissionInfo(permission))
+ c.ResponseOk(newAgentPermissionInfo(permission, c.Input().Get("owner")))
+}
+
+// GetAgentPermissions lists what every configured agent is held to, for the
+// page that shows them side by side.
+func (c *ApiController) GetAgentPermissions() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ permissions, err := object.GetAgentPermissions()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ stored := []*object.AgentPermission{}
+ for _, permission := range permissions {
+ stored = append(stored, permission)
+ }
+ c.ResponseOk(stored)
}
// UpdateAgentPermission stores the switches of one agent's Permissions card.
@@ -72,6 +112,7 @@ func (c *ApiController) UpdateAgentPermission() {
var form struct {
AgentId string `json:"agentId"`
+ Owner string `json:"owner"`
Permission *object.AgentPermission `json:"permission"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
@@ -88,5 +129,5 @@ func (c *ApiController) UpdateAgentPermission() {
return
}
- c.ResponseOk(newAgentPermissionInfo(form.Permission))
+ c.ResponseOk(newAgentPermissionInfo(form.Permission, form.Owner))
}
diff --git a/object/permission_casbin.go b/object/permission_casbin.go
index 8f20c6d..ee6a032 100644
--- a/object/permission_casbin.go
+++ b/object/permission_casbin.go
@@ -20,6 +20,7 @@ package object
import (
"fmt"
+ "sort"
"strings"
"sync"
@@ -28,9 +29,9 @@ import (
)
// 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.
+// effect is casbin's priority effect: the first rule that matches decides, and
+// a request no rule matches is denied. Order is what lets one exception stand
+// in front of the "everything else" rule behind it.
const PermissionModelText = `[request_definition]
r = sub, obj, act
@@ -38,7 +39,7 @@ r = sub, obj, act
p = sub, obj, act, eft
[policy_effect]
-e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
+e = priority(p.eft) || deny
[matchers]
m = keyMatch(r.sub, p.sub) && keyMatch(r.obj, p.obj) && keyMatch(r.act, p.act)
@@ -132,7 +133,9 @@ func newPermissionEnforcer(permission *AgentPermission)
(*casbin.Enforcer, error
}
// PolicyRules compiles the switches into casbin policy lines, as
-// "sub, obj, act, eft".
+// "sub, obj, act, eft". The order is the order they are decided in: the rules
+// written by hand come first, then the exceptions, then the rule each group
+// falls back on.
func (permission *AgentPermission) PolicyRules() [][]string {
rules := [][]string{}
seen := map[string]bool{}
@@ -146,18 +149,8 @@ func (permission *AgentPermission) PolicyRules()
[][]string {
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)
- }
- }
-
+ // A hand-written rule is checked before everything the switches wrote,
which
+ // is what makes the advanced view able to carve out what they cannot
say.
for _, rule := range permission.Rules {
parsed, err := parsePolicyRule(rule)
if err != nil {
@@ -171,10 +164,103 @@ func (permission *AgentPermission) PolicyRules()
[][]string {
rules = append(rules, parsed)
}
+ appendList(add, objectModel, permission.ModelMode, permission.Models)
+ appendList(add, objectProvider, permission.ProviderMode,
permission.Providers)
+ permission.appendToolRules(add)
return rules
}
+// appendToolRules writes the lines of the switches. A group whose catch-all is
+// off is closed with one rule for the whole group, with the items left on
+// standing in front of it; any other group only names what it took away.
+func (permission *AgentPermission) appendToolRules(add func(object string,
effect string)) {
+ for _, group := range permission.toolItemsByGroup() {
+ denied := 0
+ for _, name := range group.items {
+ if permission.Tools[name] == false &&
permission.listed(name) {
+ denied++
+ }
+ }
+
+ closed := permission.Tools[group.name+"/"+otherItem] == false &&
+ permission.listed(group.name+"/"+otherItem)
+ if !closed && denied < len(group.items) {
+ for _, name := range group.items {
+ if permission.listed(name) &&
!permission.Tools[name] {
+ add(objectTool+name, effectDeny)
+ }
+ }
+ continue
+ }
+
+ for _, name := range group.items {
+ if !permission.listed(name) || permission.Tools[name] {
+ add(objectTool+name, effectAllow)
+ }
+ }
+ add(objectTool+group.name+"/*", effectDeny)
+ }
+
+ // Whatever no group took away, including the tools that fall in none of
+ // them, is allowed.
+ add(objectTool+"*", effectAllow)
+}
+
+// listed reports whether a switch was ever set. An item nobody has touched is
+// allowed, so a switch added in a later version does not silently take
+// something away from an agent configured before it existed.
+func (permission *AgentPermission) listed(name string) bool {
+ _, found := permission.Tools[name]
+ return found
+}
+
+type toolItemGroup struct {
+ name string
+ items []string
+}
+
+// toolItemsByGroup is every switch this permission is read against: the
+// built-in catalogue, plus whatever was stored beside it, such as the MCP
+// servers of one agent.
+func (permission *AgentPermission) toolItemsByGroup() []toolItemGroup {
+ order := []string{}
+ items := map[string][]string{}
+ seen := map[string]bool{}
+
+ appendItem := func(name string) {
+ group, _, found := strings.Cut(name, "/")
+ if !found || seen[name] {
+ return
+ }
+ seen[name] = true
+ if _, known := items[group]; !known {
+ order = append(order, group)
+ }
+ items[group] = append(items[group], name)
+ }
+
+ for _, name := range CatalogItemNames() {
+ appendItem(name)
+ }
+ stored := []string{}
+ for name := range permission.Tools {
+ stored = append(stored, name)
+ }
+ sort.Strings(stored)
+ for _, name := range stored {
+ appendItem(name)
+ }
+
+ groups := []toolItemGroup{}
+ for _, name := range order {
+ groups = append(groups, toolItemGroup{name: name, items:
items[name]})
+ }
+ return groups
+}
+
// appendList writes the lines of one "all / only these / all but these"
choice.
+// The exceptions come first: the first rule that matches is the one that
+// decides.
func appendList(add func(object string, effect string), prefix string, mode
string, values []string) {
switch mode {
case ListAllow:
@@ -184,10 +270,10 @@ func appendList(add func(object string, effect string),
prefix string, mode stri
add(prefix+value, effectAllow)
}
case ListDeny:
- add(prefix+"*", effectAllow)
for _, value := range values {
add(prefix+value, effectDeny)
}
+ add(prefix+"*", effectAllow)
default:
add(prefix+"*", effectAllow)
}
@@ -256,11 +342,11 @@ func (guard *AgentGuard) AllowProvider(providerId string)
bool {
// AllowTool reports whether this agent may be offered one tool, by name.
func (guard *AgentGuard) AllowTool(name string) bool {
- group := ToolGroupOf(name)
- if group == "" {
+ entry := ToolItemOf(name)
+ if entry == "" {
return true
}
- return guard.allow(objectTool + group)
+ return guard.allow(objectTool + entry)
}
// FilterProviders drops the providers this agent may not be sent to, and says
diff --git a/object/permission_tools.go b/object/permission_tools.go
index 6e8fe52..d4d46c8 100644
--- a/object/permission_tools.go
+++ b/object/permission_tools.go
@@ -12,147 +12,245 @@
// 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.
+// This file holds the catalogue of what an agent can be allowed to do, and
+// sorts the tools it offers its model into it. A tool the switch is off for is
+// taken back out of the request, so the model is never offered it.
package object
import (
"encoding/json"
"strings"
+ "sync"
)
-// The tool groups. Each is one switch on the Permissions card and one casbin
-// object, "tool:<group>".
+// The groups the permissions are drawn in, in the order they are shown.
const (
- ToolShell = "shell"
- ToolFileRead = "fileRead"
- ToolFileWrite = "fileWrite"
- ToolNetwork = "network"
- ToolMcp = "mcp"
+ GroupShell = "shell"
+ GroupRead = "read"
+ GroupWrite = "write"
+ GroupNetwork = "network"
+ GroupAgentic = "agentic"
+ GroupMcp = "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.
+// otherItem is the last item of a group: every tool of that kind Gateway does
+// not know by name. Switching it off is what closes a group for good, rather
+// than for the tools that happened to be listed when it was set.
+const otherItem = "other"
+
+// ToolItem is one switch: a thing an agent can be allowed to do, and the tool
+// names the agents call it by. The name is "<group>/<item>", which is also the
+// casbin object it is checked as.
+type ToolItem struct {
+ Name string `json:"name"`
+ Group string `json:"group"`
+ // Label names an item the UI has no wording of its own for, such as
one MCP
+ // server of this agent. The catalogue's own items are named by the
page.
+ Label string `json:"label,omitempty"`
+ // Tools are the names this covers, as the agents write them. They are
shown
+ // under the switch, since every agent names the same tool differently.
+ Tools []string `json:"tools"`
+}
+
+// ToolGroup is one group of switches, which can also be set in one go.
type ToolGroup struct {
- Name string `json:"name"`
- Examples []string `json:"examples"`
+ Name string `json:"name"`
+ Items []ToolItem `json:"items"`
}
-// 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__*"}},
+func item(group string, name string, tools ...string) ToolItem {
+ if tools == nil {
+ tools = []string{}
+ }
+ return ToolItem{Name: group + "/" + name, Group: group, Tools: tools}
+}
+
+// toolCatalog is every switch there is. The order is the order they are drawn
+// in, and the "other" item of a group always comes last.
+var toolCatalog = []ToolItem{
+ item(GroupShell, "run", "Bash", "shell", "run_shell_command",
"run_terminal_cmd", "execute_command"),
+ item(GroupShell, "output", "BashOutput"),
+ item(GroupShell, "kill", "KillShell", "KillBash"),
+ item(GroupShell, otherItem),
+
+ item(GroupRead, "file", "Read", "read_file", "view"),
+ item(GroupRead, "many", "read_many_files"),
+ item(GroupRead, "image", "view_image", "ReadImage"),
+ item(GroupRead, "list", "LS", "list_directory", "list_dir"),
+ item(GroupRead, "find", "Glob", "glob"),
+ item(GroupRead, "grep", "Grep", "grep_search", "search_file_content"),
+ item(GroupRead, "semantic", "codebase_search"),
+ item(GroupRead, "notebook", "NotebookRead"),
+ item(GroupRead, otherItem),
+
+ item(GroupWrite, "create", "Write", "write_file", "create_file"),
+ item(GroupWrite, "edit", "Edit", "edit_file", "replace",
"str_replace_editor"),
+ item(GroupWrite, "multi", "MultiEdit"),
+ item(GroupWrite, "patch", "apply_patch"),
+ item(GroupWrite, "notebook", "NotebookEdit"),
+ item(GroupWrite, "delete", "delete_file", "remove_file"),
+ item(GroupWrite, "move", "move_file", "rename_file"),
+ item(GroupWrite, "mkdir", "create_directory", "mkdir"),
+ item(GroupWrite, otherItem),
+
+ item(GroupNetwork, "fetch", "WebFetch", "web_fetch", "fetch"),
+ item(GroupNetwork, "search", "WebSearch", "web_search",
"google_web_search"),
+ item(GroupNetwork, "browser", "browser_navigate", "browser_click",
"playwright"),
+ item(GroupNetwork, otherItem),
+
+ item(GroupAgentic, "subagent", "Task", "Agent"),
+ item(GroupAgentic, "todo", "TodoWrite", "TodoRead", "update_plan"),
+ item(GroupAgentic, "plan", "ExitPlanMode"),
+ item(GroupAgentic, "ask", "AskUserQuestion"),
+ item(GroupAgentic, "command", "SlashCommand"),
+ item(GroupAgentic, "skill", "Skill"),
+ item(GroupAgentic, "memory", "save_memory"),
+
+ // One switch per MCP server is added to this group per agent, from the
+ // servers that agent has installed; this one covers the rest.
+ item(GroupMcp, otherItem),
}
-// ToolGroups lists the groups, for the UI that draws a switch per group.
-func ToolGroups() []ToolGroup {
- groups := make([]ToolGroup, len(toolGroups))
- copy(groups, toolGroups)
+// groupOrder is the order the groups are drawn in.
+var groupOrder = []string{GroupShell, GroupRead, GroupWrite, GroupNetwork,
GroupAgentic, GroupMcp}
+
+// ToolGroups is the catalogue as the UI draws it, with the extra items an
agent
+// has of its own - its MCP servers - added to the group they belong to.
+func ToolGroups(extra []ToolItem) []ToolGroup {
+ groups := []ToolGroup{}
+ for _, name := range groupOrder {
+ group := ToolGroup{Name: name, Items: []ToolItem{}}
+ for _, entry := range toolCatalog {
+ if entry.Group != name || entry.isOther() {
+ continue
+ }
+ group.Items = append(group.Items, entry)
+ }
+ for _, entry := range extra {
+ if entry.Group == name {
+ group.Items = append(group.Items, entry)
+ }
+ }
+ // The catch-all always comes last, whatever was added above it.
+ for _, entry := range toolCatalog {
+ if entry.Group == name && entry.isOther() {
+ group.Items = append(group.Items, entry)
+ }
+ }
+ groups = append(groups, group)
+ }
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": "",
+func (entry ToolItem) isOther() bool {
+ return strings.HasSuffix(entry.Name, "/"+otherItem)
+}
+
+// McpServerItem is the switch for one MCP server an agent has installed. The
+// tools of that server arrive named "mcp__<server>__<tool>", so the item is
+// named after the server and matches every tool it offers.
+func McpServerItem(server string) ToolItem {
+ return ToolItem{
+ Name: GroupMcp + "/" +
normalizeToolName(strings.ToLower(server)),
+ Group: GroupMcp,
+ Label: server,
+ Tools: []string{"mcp__" + server + "__*"},
+ }
}
-// toolKeywords catch the tools of an agent Gateway has never seen, in the
order
-// they are tried.
+// CatalogItemNames is every switch of the built-in catalogue, which is what a
+// stored permission is read against.
+func CatalogItemNames() []string {
+ names := []string{}
+ for _, entry := range toolCatalog {
+ names = append(names, entry.Name)
+ }
+ return names
+}
+
+// toolIndex maps a tool's name, normalized, to the item it falls under. It is
+// built from the catalogue so the two can never drift apart.
+var (
+ toolIndexOnce sync.Once
+ toolIndex map[string]string
+)
+
+func itemOfTool(normalized string) (string, bool) {
+ toolIndexOnce.Do(func() {
+ toolIndex = map[string]string{}
+ for _, entry := range toolCatalog {
+ for _, tool := range entry.Tools {
+
toolIndex[normalizeToolName(strings.ToLower(tool))] = entry.Name
+ }
+ }
+ })
+ name, found := toolIndex[normalized]
+ return name, found
+}
+
+// toolKeywords catch the tools of an agent Gateway has never seen. A tool they
+// match belongs to that group's "other" item, in the order tried here.
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"}},
+ {GroupNetwork, []string{"web", "http", "url", "browser", "curl",
"download", "internet", "google", "fetch"}},
+ {GroupShell, []string{"bash", "shell", "terminal", "command", "cmd",
"exec", "process", "script"}},
+ {GroupWrite, []string{"write", "edit", "patch", "create", "delete",
"remove", "move", "rename", "insert", "replace", "mkdir"}},
+ {GroupRead, []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
+// ToolItemOf is the switch one tool is held to, or "" for a tool that fits
none
// of them and is therefore always allowed.
-func ToolGroupOf(name string) string {
+func ToolItemOf(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
+ // asking: it is the one item a name says outright.
+ if server, ok := mcpServerOf(lower); ok {
+ if server == "" {
+ return GroupMcp + "/" + otherItem
+ }
+ return GroupMcp + "/" + server
}
normalized := normalizeToolName(lower)
- if group, listed := toolNames[normalized]; listed {
- return group
+ if entry, found := itemOfTool(normalized); found {
+ return entry
}
for _, entry := range toolKeywords {
for _, keyword := range entry.keywords {
if strings.Contains(normalized, keyword) {
- return entry.group
+ return entry.group + "/" + otherItem
}
}
}
return ""
}
+// mcpServerOf reads the server out of an MCP tool name, which the agents write
+// as "mcp__<server>__<tool>".
+func mcpServerOf(lower string) (string, bool) {
+ for _, prefix := range []string{"mcp__", "mcp_", "mcp."} {
+ if !strings.HasPrefix(lower, prefix) {
+ continue
+ }
+ rest := strings.TrimPrefix(lower, prefix)
+ for _, separator := range []string{"__", "_", "."} {
+ if index := strings.Index(rest, separator); index > 0 {
+ return normalizeToolName(rest[:index]), true
+ }
+ }
+ return normalizeToolName(rest), true
+ }
+ return "", false
+}
+
// 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 {
@@ -165,10 +263,10 @@ func normalizeToolName(lower string) string {
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.
+// FilterTools takes the tools 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
@@ -186,8 +284,8 @@ func (guard *AgentGuard) FilterTools(body []byte) ([]byte,
[]string) {
kept := []json.RawMessage{}
removed := []string{}
- for _, item := range items {
- filtered, names := guard.filterTool(item)
+ for _, entry := range items {
+ filtered, names := guard.filterTool(entry)
removed = append(removed, names...)
if filtered != nil {
kept = append(kept, filtered)
@@ -220,8 +318,8 @@ func (guard *AgentGuard) FilterTools(body []byte) ([]byte,
[]string) {
// 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 {
+func (guard *AgentGuard) filterTool(entry json.RawMessage) (json.RawMessage,
[]string) {
+ fields := struct {
Name string `json:"name"`
Type string `json:"type"`
Function *struct {
@@ -229,59 +327,63 @@ func (guard *AgentGuard) filterTool(item json.RawMessage)
(json.RawMessage, []st
} `json:"function"`
FunctionDeclarations []json.RawMessage
`json:"functionDeclarations"`
}{}
- if err := json.Unmarshal(item, &entry); err != nil {
- return item, nil
+ if err := json.Unmarshal(entry, &fields); err != nil {
+ return entry, 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
+ if fields.FunctionDeclarations != nil {
+ return guard.filterDeclarations(entry,
fields.FunctionDeclarations)
}
- name := entry.Name
- if name == "" && entry.Function != nil {
- name = entry.Function.Name
+ name := fields.Name
+ if name == "" && fields.Function != nil {
+ name = fields.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
+ name = fields.Type
}
if name == "" || guard.AllowTool(name) {
- return item, nil
+ return entry, nil
}
return nil, []string{name}
}
+
+func (guard *AgentGuard) filterDeclarations(entry json.RawMessage,
declarations []json.RawMessage) (json.RawMessage, []string) {
+ kept := []json.RawMessage{}
+ removed := []string{}
+ for _, declaration := range declarations {
+ named := struct {
+ Name string `json:"name"`
+ }{}
+ if err := json.Unmarshal(declaration, &named); err != nil ||
guard.AllowTool(named.Name) {
+ kept = append(kept, declaration)
+ continue
+ }
+ removed = append(removed, named.Name)
+ }
+ if len(removed) == 0 {
+ return entry, nil
+ }
+ if len(kept) == 0 {
+ return nil, removed
+ }
+
+ fields := map[string]json.RawMessage{}
+ if err := json.Unmarshal(entry, &fields); err != nil {
+ return entry, nil
+ }
+ encoded, err := json.Marshal(kept)
+ if err != nil {
+ return entry, nil
+ }
+ fields["functionDeclarations"] = encoded
+ rewritten, err := json.Marshal(fields)
+ if err != nil {
+ return entry, nil
+ }
+ return rewritten, removed
+}
diff --git a/routers/router.go b/routers/router.go
index fa24283..60b0e92 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -62,6 +62,7 @@ func initAPI() {
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/get-agent-permissions",
&controllers.ApiController{}, "GET:GetAgentPermissions")
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")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3f5e666..fc13691 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -23,6 +23,7 @@ import * as Setting from "@/Setting";
import {findGroupOf, selectedKeyOf} from "@/nav";
import {AppHeader} from "@/components/shared/app-header";
import {AppSidebar, persistOpenKeys, readSavedOpenKeys, useIsDesktop} from
"@/components/shared/app-sidebar";
+import {FirstRunNoticeDialog} from "@/components/FirstRunNoticeDialog";
import {Loading} from "@/components/shared/loading";
import {TooltipProvider} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
@@ -36,6 +37,7 @@ import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
import AuthenticityPage from "@/pages/AuthenticityPage";
import HomePage from "@/pages/HomePage";
+import PermissionsPage from "@/pages/PermissionsPage";
import ProviderEditPage from "@/pages/ProviderEditPage";
import ProviderListPage from "@/pages/ProviderListPage";
import LlmRecordsPage from "@/pages/LlmRecordsPage";
@@ -210,6 +212,7 @@ export default function App() {
return (
<TooltipProvider>
+ {Setting.isAdminUser(account) && <FirstRunNoticeDialog />}
<div className="bg-muted/30 min-h-screen">
<AppSidebar
collapsed={collapsed}
@@ -278,6 +281,10 @@ export default function App() {
element={requireSignin(user => <AgentSessionPage
account={user} />)}
/>
<Route path="/settings" element={requireSignin(user =>
<SettingPage account={user} />)} />
+ <Route
+ path="/permissions"
+ element={requireSignin(user => <PermissionsPage
account={user} />)}
+ />
<Route path="/providers" element={requireSignin(user =>
<ProviderListPage account={user} />)} />
<Route
path="/authenticity"
diff --git a/web/src/backend/PermissionBackend.ts
b/web/src/backend/PermissionBackend.ts
index 8768063..9323a92 100644
--- a/web/src/backend/PermissionBackend.ts
+++ b/web/src/backend/PermissionBackend.ts
@@ -15,15 +15,24 @@
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})}`);
+/** What one agent may do. An agent nobody has configured answers with the
+ * unrestricted default rather than with nothing. The owner is the host user
the
+ * installation belongs to, which is where its MCP servers are read from. */
+export function getAgentPermission(agentId: string, owner = "") {
+ return request<AgentPermissionInfo>(
+ `/api/get-agent-permission${query({agentId: agentId, owner: owner})}`,
+ );
}
-export function updateAgentPermission(agentId: string, permission:
AgentPermission) {
+/** Every agent somebody has configured, for the list on the Permissions page.
*/
+export function getAgentPermissions() {
+ return request<AgentPermission[]>("/api/get-agent-permissions");
+}
+
+export function updateAgentPermission(agentId: string, permission:
AgentPermission, owner = "") {
return request<AgentPermissionInfo>("/api/update-agent-permission", "POST", {
agentId: agentId,
+ owner: owner,
permission: permission,
});
}
diff --git a/web/src/components/PermissionCard.tsx
b/web/src/components/PermissionCard.tsx
index f0886fa..4de6839 100644
--- a/web/src/components/PermissionCard.tsx
+++ b/web/src/components/PermissionCard.tsx
@@ -13,7 +13,19 @@
// limitations under the License.
import * as React from "react";
-import {Copy, FileCode2, FolderOpen, Globe, Pencil, Plug, ShieldCheck,
Terminal} from "lucide-react";
+import {
+ ChevronDown,
+ ChevronRight,
+ Copy,
+ FileCode2,
+ FolderOpen,
+ Globe,
+ ListChecks,
+ Pencil,
+ Plug,
+ ShieldCheck,
+ Terminal,
+} from "lucide-react";
import copy from "copy-to-clipboard";
import i18next from "i18next";
@@ -29,18 +41,69 @@ 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";
+import type {
+ Agent,
+ AgentPermission,
+ AgentPermissionInfo,
+ Provider,
+ ToolGroup,
+ ToolItem,
+} 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. */
+/** The name and icon of each group, by the name the server gives it. */
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},
+ shell: {label: "agent:Terminal", icon: Terminal},
+ read: {label: "agent:Reading the project", icon: FolderOpen},
+ write: {label: "agent:Changing the project", icon: Pencil},
+ network: {label: "agent:The internet", icon: Globe},
+ agentic: {label: "agent:Planning and delegation", icon: ListChecks},
+ mcp: {label: "agent:MCP servers", icon: Plug},
};
+/** What each switch is called. An item with no wording here - one of the
agent's
+ * own MCP servers - is named by the label the server sent. */
+const itemLabels: {[item: string]: string} = {
+ "shell/run": "agent:Run a command",
+ "shell/output": "agent:Read a running command's output",
+ "shell/kill": "agent:Stop a running command",
+ "shell/other": "agent:Any other command tool",
+ "read/file": "agent:Read a file",
+ "read/many": "agent:Read many files at once",
+ "read/image": "agent:Look at an image",
+ "read/list": "agent:List a directory",
+ "read/find": "agent:Find files by name",
+ "read/grep": "agent:Search inside files",
+ "read/semantic": "agent:Search the codebase by meaning",
+ "read/notebook": "agent:Read a notebook",
+ "read/other": "agent:Any other tool that reads",
+ "write/create": "agent:Create or overwrite a file",
+ "write/edit": "agent:Edit a file",
+ "write/multi": "agent:Edit several places at once",
+ "write/patch": "agent:Apply a patch",
+ "write/notebook": "agent:Edit a notebook",
+ "write/delete": "agent:Delete a file",
+ "write/move": "agent:Move or rename a file",
+ "write/mkdir": "agent:Create a directory",
+ "write/other": "agent:Any other tool that writes",
+ "network/fetch": "agent:Fetch a URL",
+ "network/search": "agent:Search the web",
+ "network/browser": "agent:Drive a browser",
+ "network/other": "agent:Any other tool that goes online",
+ "agentic/subagent": "agent:Start a sub-agent",
+ "agentic/todo": "agent:Keep a task list",
+ "agentic/plan": "agent:Leave plan mode",
+ "agentic/ask": "agent:Ask the user a question",
+ "agentic/command": "agent:Run a slash command",
+ "agentic/skill": "agent:Run a skill",
+ "agentic/memory": "agent:Remember something",
+ "mcp/other": "agent:Any other MCP server",
+};
+
+function itemLabel(item: ToolItem) {
+ const known = itemLabels[item.name];
+ return known ? i18next.t(known) : item.label || item.name.split("/")[1];
+}
+
/** The three ways a list of models or providers is read. */
const listModes = ["all", "allow", "deny"];
@@ -52,34 +115,97 @@ function modeOptions(anyLabel: string) {
];
}
-/** One tool group, as the switch that turns it off. */
-function ToolSwitch({
+/** An item is allowed until somebody turns it off, so a switch this agent was
+ * configured before it existed never takes anything away. */
+function isAllowed(tools: AgentPermission["tools"], item: string) {
+ return (tools ?? {})[item] !== false;
+}
+
+/** One group: its own switch sets every item in it at once, and the items
+ * underneath are what a finer answer is given with. */
+function GroupBlock({
group,
- allowed,
+ tools,
busy,
- onChange,
+ onItems,
}: {
group: ToolGroup;
- allowed: boolean;
+ tools: AgentPermission["tools"];
busy: boolean;
- onChange: (allowed: boolean) => void;
+ onItems: (changed: {[item: string]: boolean}) => void;
}) {
+ const [open, setOpen] = React.useState(false);
const known = groupLabels[group.name];
const Icon = known ? known.icon : ShieldCheck;
+ const items = group.items ?? [];
+ const blocked = items.filter(item => !isAllowed(tools, item.name));
+ const Chevron = open ? ChevronDown : ChevronRight;
+
+ const setAll = (allowed: boolean) => {
+ const changed: {[item: string]: boolean} = {};
+ items.forEach(item => {
+ changed[item.name] = allowed;
+ });
+ onItems(changed);
+ };
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>
+ <div className="rounded-md border">
+ <div className="flex items-center gap-2 p-2.5">
+ <button
+ type="button"
+ onClick={() => setOpen(!open)}
+ className="flex min-w-0 flex-1 items-center gap-2 text-left text-sm"
+ >
+ <Chevron className="h-4 w-4 shrink-0 text-muted-foreground" />
+ <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
+ <span className="truncate font-medium">
+ {known ? i18next.t(known.label) : group.name}
+ </span>
+ {blocked.length === 0 ? (
+ <Badge variant="muted" className="shrink-0 font-normal">
+ {`${items.length} ${i18next.t("agent:allowed")}`}
+ </Badge>
+ ) : (
+ <Badge variant="warning" className="shrink-0 font-normal">
+ {`${blocked.length} ${i18next.t("agent:blocked")}`}
+ </Badge>
+ )}
+ </button>
+ <Switch
+ checked={blocked.length === 0}
+ disabled={busy}
+ aria-label={known ? i18next.t(known.label) : group.name}
+ onCheckedChange={setAll}
+ />
+ </div>
+
+ {open ? (
+ <div className="grid grid-cols-1 gap-x-4 border-t p-2.5
sm:grid-cols-2">
+ {items.map(item => (
+ <label
+ key={item.name}
+ className="flex items-start justify-between gap-3 border-b py-2
text-sm last:border-b-0 sm:[&:nth-last-child(2)]:border-b-0"
+ >
+ <span className="min-w-0">
+ {itemLabel(item)}
+ {(item.tools ?? []).length > 0 ? (
+ <code className="block truncate text-xs
text-muted-foreground">
+ {(item.tools ?? []).join(", ")}
+ </code>
+ ) : null}
+ </span>
+ <Switch
+ className="mt-0.5 shrink-0"
+ checked={isAllowed(tools, item.name)}
+ disabled={busy}
+ onCheckedChange={allowed => onItems({[item.name]: allowed})}
+ />
+ </label>
+ ))}
+ </div>
+ ) : null}
+ </div>
);
}
@@ -144,7 +270,7 @@ function AdvancedView({
spellCheck={false}
value={draft}
disabled={busy}
- placeholder="claude-code, tool:mcp, use, deny"
+ 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 !== "");
@@ -160,26 +286,39 @@ function AdvancedView({
}
/**
- * 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.
+ * 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[]}) {
+export function PermissionCard({
+ agent,
+ providers,
+ className,
+ onSaved,
+}: {
+ agent: Agent;
+ providers: Provider[];
+ className?: string;
+ /** Told after a save, for a page that lists what every agent is held to. */
+ onSaved?: () => void;
+}) {
const [info, setInfo] = React.useState<AgentPermissionInfo | null>(null);
const [busy, setBusy] = React.useState(false);
const [advanced, setAdvanced] = React.useState(false);
const agentId = agent.agentId;
+ const owner = agent.owner;
React.useEffect(() => {
if (agentId === "") {
return;
}
- PermissionBackend.getAgentPermission(agentId)
+ setInfo(null);
+ PermissionBackend.getAgentPermission(agentId, owner)
.then(res => setInfo(res.status === "ok" ? (res.data ?? null) : null))
.catch(() => setInfo(null));
- }, [agentId]);
+ }, [agentId, owner]);
if (info === null) {
return null;
@@ -187,15 +326,17 @@ export function PermissionCard({agent, providers}:
{agent: Agent; providers: Pro
const permission = info.permission;
const groups = info.groups ?? [];
- const blocked = groups.filter(group => permission.tools?.[group.name] ===
false);
+ const items = groups.flatMap(group => group.items ?? []);
+ const blocked = items.filter(item => !isAllowed(permission.tools,
item.name));
const save = (changed: Partial<AgentPermission>) => {
const next = {...permission, ...changed};
setBusy(true);
- PermissionBackend.updateAgentPermission(agentId, next)
+ PermissionBackend.updateAgentPermission(agentId, next, owner)
.then(res => {
if (res.status === "ok" && res.data) {
setInfo(res.data);
+ onSaved?.();
Setting.showMessage("success", i18next.t("agent:Permissions saved"));
} else {
Setting.showMessage("error", res.msg || i18next.t("agent:Failed to
save the permissions"));
@@ -205,8 +346,16 @@ export function PermissionCard({agent, providers}: {agent:
Agent; providers: Pro
.then(() => setBusy(false));
};
- const setTool = (group: string, allowed: boolean) =>
- save({tools: {...(permission.tools ?? {}), [group]: allowed}});
+ // Every switch the page drew is sent back, so an item nobody touched is
+ // stored as allowed rather than as unknown: that is what lets a group be
+ // closed with one rule instead of one per item.
+ const saveItems = (changed: {[item: string]: boolean}) => {
+ const tools: {[item: string]: boolean} = {};
+ items.forEach(item => {
+ tools[item.name] = isAllowed(permission.tools, item.name);
+ });
+ save({tools: {...tools, ...changed}});
+ };
// The models of every provider, so the field offers what this machine can
// actually reach rather than an empty box.
@@ -217,17 +366,19 @@ export function PermissionCard({agent, providers}:
{agent: Agent; providers: Pro
const listMode = (mode: string) => (listModes.includes(mode) ? mode : "all");
return (
- <Card>
+ <Card className={className}>
<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>
- ) : (
+ {!permission.enabled ? (
<Badge variant="muted">{i18next.t("agent:Unrestricted")}</Badge>
+ ) : blocked.length === 0 ? (
+ <Badge variant="muted">{i18next.t("agent:Enforced")}</Badge>
+ ) : (
+ <Badge variant="success">
+ {`${blocked.length} ${i18next.t("agent:blocked")}`}
+ </Badge>
)}
</CardTitle>
<Button variant="ghost" size="xs" onClick={() =>
setAdvanced(!advanced)}>
@@ -260,15 +411,20 @@ export function PermissionCard({agent, providers}:
{agent: Agent; providers: Pro
<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">
+ <div className="flex flex-wrap items-center justify-between gap-2">
+ <div className="text-sm font-medium">{i18next.t("agent:What it
may do")}</div>
+ <div className="text-xs text-muted-foreground">
+ {`${items.length} ${i18next.t("agent:switches")}`}
+ </div>
+ </div>
+ <div className="space-y-2">
{groups.map(group => (
- <ToolSwitch
+ <GroupBlock
key={group.name}
group={group}
- allowed={permission.tools?.[group.name] !== false}
+ tools={permission.tools}
busy={busy || !permission.enabled}
- onChange={allowed => setTool(group.name, allowed)}
+ onItems={saveItems}
/>
))}
</div>
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 282af96..5564eea 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -39,7 +39,14 @@
"All event types": "All event types",
"All outcomes": "All outcomes",
"Any model": "Any model",
+ "Any other MCP server": "Any other MCP server",
+ "Any other command tool": "Any other command tool",
+ "Any other tool that goes online": "Any other tool that goes online",
+ "Any other tool that reads": "Any other tool that reads",
+ "Any other tool that writes": "Any other tool that writes",
"Any provider": "Any provider",
+ "Apply a patch": "Apply a patch",
+ "Ask the user a question": "Ask the user a question",
"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",
@@ -51,8 +58,8 @@
"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",
+ "Casbin hint": "The switches above compile to this policy, and the first
rule that matches decides. Casbin is what every relayed request is put
through.",
+ "Changing the project": "Changing the project",
"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.",
@@ -62,12 +69,19 @@
"Configuration written": "Configuration written",
"Cooling down": "Cooling down",
"Copy path": "Copy path",
+ "Create a directory": "Create a directory",
+ "Create or overwrite a file": "Create or overwrite a file",
+ "Delete a file": "Delete a file",
"Detail": "Detail",
"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.",
+ "Drive a browser": "Drive a browser",
"Duration": "Duration",
+ "Edit a file": "Edit a file",
+ "Edit a notebook": "Edit a notebook",
+ "Edit several places at once": "Edit several places at once",
"Empty message": "Empty message",
"Enable": "Enable",
"Enforce permissions": "Enforce permissions",
@@ -75,7 +89,7 @@
"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.",
+ "Extra rules hint": "One policy line per row, as \"sub, obj, act, eft\".
They are checked before everything the switches wrote, so they can carve out
what the switches cannot say.",
"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",
@@ -89,7 +103,9 @@
"Failed to update agent provider": "Failed to update agent provider",
"Failed to write the agent configuration": "Failed to write the agent
configuration",
"Fallback hint": "Providers tried, in the order picked, when the bound one
cannot answer.",
+ "Fetch a URL": "Fetch a URL",
"Filter": "Filter",
+ "Find files by name": "Find files by name",
"First activity": "First activity",
"From the transcript": "From the transcript",
"Gateway": "Gateway",
@@ -110,8 +126,13 @@
"Instance removed": "Instance removed",
"Instances": "Instances",
"Instances hint": "Extra copies of this agent, each with a state directory
of its own, so each is signed in to its own account and they run at the same
time.",
+ "Keep a task list": "Keep a task list",
"Last activity": "Last activity",
"Last {count} records": "Last {count} records",
+ "Leave plan mode": "Leave plan mode",
+ "List a directory": "List a directory",
+ "Look at an image": "Look at an image",
+ "MCP servers": "MCP servers",
"MCP target": "MCP target",
"Model": "Model",
"Model list hint": "A name may end in \"*\", so \"claude-opus-*\" covers
the whole family.",
@@ -120,6 +141,8 @@
"Monitoring disabled": "Monitoring disabled",
"Monitoring enabled": "Monitoring enabled",
"Monitoring, no activity yet": "Monitoring, no activity yet",
+ "Move or rename a file": "Move or rename a file",
+ "No agent found": "No agent found",
"No agent records yet - patch an agent to start collecting them": "No
activity yet - turn on monitoring for an agent to start collecting it",
"No agent sessions yet": "No agent sessions yet",
"No agent sessions yet - patch an agent to start collecting them": "No
sessions yet - turn on monitoring for an agent to start collecting them",
@@ -150,7 +173,10 @@
"Path copied to clipboard": "Path copied to clipboard",
"Payload": "Payload",
"Permissions": "Permissions",
+ "Permissions empty hint": "No agent was found on this machine, so there is
nothing to hold to a rule yet.",
+ "Permissions page hint": "What every agent on this machine may ask Gateway
for. You set the switches; Casbin decides every request they relay.",
"Permissions saved": "Permissions saved",
+ "Planning and delegation": "Planning and delegation",
"Preview": "Preview",
"Preview hint": "What Gateway would write, with the keys masked.
Everything else in these files is left as it is.",
"Processes": "Processes",
@@ -159,11 +185,15 @@
"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 a file": "Read a file",
+ "Read a notebook": "Read a notebook",
+ "Read a running command's output": "Read a running command's output",
"Read from the agent's own logs": "Read from the agent's own logs",
+ "Read many files at once": "Read many files at once",
+ "Reading the project": "Reading the project",
"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",
+ "Remember something": "Remember something",
"Remove instance": "Remove instance",
"Remove instance hint": "Gateway forgets this instance. Its state
directory, and the account signed in to it, are left on disk.",
"Reported from": "Reported from",
@@ -174,24 +204,33 @@
"Rewrite configuration": "Rewrite configuration",
"Route through Gateway": "Route through Gateway",
"Run Status": "Run Status",
- "Run commands": "Run commands",
+ "Run a command": "Run a command",
+ "Run a skill": "Run a skill",
+ "Run a slash command": "Run a slash command",
"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.",
"Scan": "Rescan",
+ "Search inside files": "Search inside files",
+ "Search the codebase by meaning": "Search the codebase by meaning",
+ "Search the web": "Search the web",
"Session": "Session",
"Session title": "Session title",
"Source": "Source",
"Start": "Start",
+ "Start a sub-agent": "Start a sub-agent",
"Start app hint": "Opens the app on the machine Gateway runs on.",
"Start console hint": "Opens a console window running the CLI on the
machine Gateway runs on.",
"State directory": "State directory",
"Still calling Gateway": "This agent is set to its own model, but its
configuration still points at Gateway, so every request fails. Put the
configuration back.",
"Stop": "Stop",
+ "Stop a running command": "Stop a running command",
"Stop hint": "Ends every process of this installation, losing whatever it
is working on.",
"Stopped": "Stopped",
"Switch takes effect at once": "Picking another provider above rewrites
this file at once, so switching takes one click.",
"Target / Model": "Target / Model",
+ "Terminal": "Terminal",
+ "The internet": "The internet",
"Thinking": "Thinking",
"This transcript holds no conversation": "This transcript holds no
conversation",
"Time": "Time",
@@ -199,7 +238,7 @@
"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 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 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",
@@ -213,7 +252,6 @@
"Upgrade {agent}?": "Upgrade {agent}?",
"Upgrading": "Upgrading",
"Usage": "Usage",
- "Use MCP servers": "Use MCP servers",
"User": "User",
"Version": "Version",
"View Records": "View Records",
@@ -224,7 +262,10 @@
"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}?",
"Written by Gateway": "Written by Gateway",
+ "allowed": "allowed",
+ "blocked": "blocked",
"opencode config hint": "opencode reads no base URL from the environment:
the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json,
loaded through @ai-sdk/openai-compatible and selected with \"model\":
\"<provider>/<model>\".",
+ "switches": "switches",
"{count} messages": "{count} messages"
},
"agentConfig": {
@@ -410,11 +451,17 @@
"{files} files": "{files} files"
},
"audit": {
+ "Accepted answers": "Accepted answers",
+ "Accepted answers hint": "Any one of these passes the case. Leave empty to
accept whatever comes back.",
"Alert outside": "Alert outside",
"Allowed drift": "Allowed drift",
"Allowed drift hint": "How far two byte-identical requests may be billed
apart before it counts as invented.",
+ "Answer empty": "Nothing was said, which is the answer length or the
thinking rather than the answer.",
+ "Answer forbidden": "The answer carries \"{forbidden}\", which this case
rules out: {answer}",
"Answer length": "Answer length",
"Answer length hint": "The max_tokens the request asks for. The envelope
is what is measured, not the writing.",
+ "Answer right": "It answered: {answer}",
+ "Answer wrong": "It answered \"{answer}\", where the answer is
{expected}.",
"Authenticity": "Authenticity",
"Authenticity description": "Whether the API behind each key is what it
was sold as. Every provider is probed on its own, without being asked, and
graded on a published suite of test cases: what each case asks, how it decides
and what it is worth are all below, and all of them can be changed.",
"Billing detail": "{billed} input tokens billed for about {estimate} sent.
Four characters to the token is rough, so only a wide gap means anything.",
@@ -436,6 +483,8 @@
"Channel audit": "Channel audit",
"Check": "Check",
"Check hint": "Which engine runs the case: what it sends and how it reads
the answer.",
+ "Compared as": "Compared as",
+ "Compared as hint": "Contains looks for the words anywhere; exact compares
the whole answer with the casing and punctuation taken off; regex matches a
pattern.",
"Complete": "Complete",
"Custom case": "Custom",
"Defaults restored": "The shipped suite is back",
@@ -443,10 +492,15 @@
"Delete custom case detail": "This case was written here. Deleting it
cannot be undone.",
"Delete this test case?": "Delete this test case?",
"Different": "Different",
+ "Documented parameter": "Documented parameter",
+ "Dropped": "Dropped",
+ "Echoed": "Echoed",
"Edit test case": "Edit test case",
"Every model has a rate": "Every model this provider served has a
published rate.",
"Expected events": "Expected events",
"Expected events hint": "The event names this API documents. Anything
missing is the finding.",
+ "Extra hint": "A JSON object merged into the request body, which is how a
case sends the parameter it is about.",
+ "Extra request fields": "Extra request fields",
"Failed attempts": "Failed attempts",
"Failed cases": "{count} failed",
"Failed over": "Failed over",
@@ -454,6 +508,7 @@
"Failures detail": "{failed} of {sample} attempts did not answer.",
"First byte": "First byte after {ttft}.",
"Flagged cases": "{count} flagged",
+ "Found": "Found",
"Grade": "Grade",
"Grade A": "Answers as documented",
"Grade A detail": "Every case that could be measured answered the way the
vendor's own API documents.",
@@ -472,14 +527,22 @@
"Headers looked for": "Headers looked for",
"Headers required": "Headers required",
"Held": "Held",
+ "Hidden found": "The request carried no system prompt, and something else
came back: {answer}",
+ "Hidden instructions": "Hidden instructions",
+ "Hidden none": "Nothing stands above this request but what this gateway
sent.",
+ "Honored": "Honored",
"Identity alias": "Asked for {asked} and {answered} answered, which is the
model this vendor documents that name as pointing at.",
"Identity different": "Asked for {asked} and the answer named {answered}.",
"Identity has no request": "This case sends nothing of its own: it reads
the model name off the first answer of the run.",
"Identity ok": "Answered as {answered}. Echoing a name back is easy, so
this is a floor rather than a proof.",
"Identity reads": "The model name on the first answer of the run.",
+ "Identity unverified": "The name that came back is the name that was sent
({answered}), and no vendor documents a model by that name. An echo is not
something that can be checked.",
"Last seen": "Last seen",
"Latency detail": "Slowest 5% from {p95}, {ratio}x the median.",
"Lost": "Lost",
+ "Match contains": "Contains",
+ "Match exact": "Exact",
+ "Match regex": "Regex",
"Matches": "Matches",
"Measuring now": "Being measured. Each provider is probed shortly after it
is added.",
"Method": "Method",
@@ -501,9 +564,15 @@
"Nothing to audit yet detail": "The audit reads records the proxy already
kept. Point an agent at a provider and its first requests will show up here.",
"Nothing was relayed in this window": "Nothing was relayed in this
window.",
"Only the newest records were read": "This window holds more records than
one audit reads, so only the newest {count} were counted.",
+ "Only these vendors": "Only these vendors",
"Order": "Order",
"Order hint": "Lower runs and reads first.",
"Page description": "What each upstream looks like from here, from both
sides. Traffic reads records Gateway already kept and costs nothing. A probe
asks the upstream directly, running the published test cases whose right
answers the vendor's own API documents, and spends a few cents of that
provider's credit. Every figure names what it was measured over, because a rate
over a handful of requests is not a finding.",
+ "Parameter dropped": "The parameter was accepted and not applied: the
answer is \"{answer}\" where it should be {wanted}.",
+ "Parameter honored": "The parameter was honored the way the API
documents.",
+ "Parameter ignored": "The request was accepted with a 200 and the answer
carries no {field}: the parameter was never passed on.",
+ "Parameter rejected": "The upstream refused the parameter outright, which
is the vendor not implementing it. Nothing was measured, and nothing was
scored: {reason}",
+ "Parameter shape": "{field} came back as {value}, which does not match
{wanted}.",
"Pricing detail": "{unpriced} of {sample} answered requests used a model
no price list names.",
"Probe": "Probe",
"Probe again": "Probe again",
@@ -520,24 +589,44 @@
"Providers alerting": "{count} with a failed case",
"Providers unmeasured": "{count} not measured yet",
"Question": "Question",
+ "Question failed": "This question could not be asked: {reason}",
"Question hint": "What this case asks the upstream, in one sentence.",
+ "Question prompt hint": "What is actually sent to the upstream.",
"Read from {count} records": "Read from {count} records",
"Reads": "Reads",
"Recording is off detail": "The audit has nothing to read while
llmRecordMode is \"off\". Metadata alone is enough for every check here;
prompts do not have to be stored.",
+ "Rejected answers": "Rejected answers",
+ "Rejected answers hint": "An answer carrying any of these fails the case.",
"Removed": "No longer configured",
+ "Repeat answers": "Identical requests were answered differently:
\"{first}\" and \"{second}\". Sampling is allowed to differ, so this is a
note.",
+ "Repeat models": "Identical requests came back with different model names:
{names}",
+ "Repeat same": "{count} identical requests came back with the same model
name and the same input count.",
+ "Repeat tokens": "The same bytes were counted as different amounts of
input: {counts}. A different tokenizer is a different model.",
+ "Repeated request": "Repeated request",
"Report": "Report",
+ "Requests sent": "Requests sent",
+ "Requests sent hint": "How many times the identical request goes out, from
two to six.",
+ "Required answer fields": "Required answer fields",
+ "Required hint": "Dotted paths into the answer, like choices.0.logprobs.
Add =pattern to also require what the value looks like. A missing field is the
parameter having been dropped.",
"Response time": "Response time",
"Restore defaults": "Restore defaults",
"Restore detail": "Every built-in case goes back to the way it shipped and
the deleted ones return. Cases you wrote are left alone.",
"Restore the shipped suite?": "Restore the shipped suite?",
+ "Right": "Right",
"Run this case": "Run this case",
"Score from cases": "{measured} of {total} cases measured",
"Second request after": "Second request after",
"See the evidence": "See the evidence",
+ "Self match": "It says it is {vendor}'s: {answer}",
+ "Self other": "Sold as a model of {vendor}, it says it is {other}'s:
{answer}",
+ "Self silent": "It never names {vendor}, and never names anyone else
either: {answer}",
+ "Self undocumented": "The name this was sold under belongs to no vendor's
catalogue, so there is nothing its answer would have to agree with.",
+ "Self-reported maker": "Self-reported maker",
"Stream complete": "Every event this API documents arrived, in order.",
"Stream missing": "Missing or empty: {items}.",
"Stream shape": "Stream shape",
"System prompt": "System prompt",
+ "Test bank": "Test bank",
"Test case saved": "Test case saved",
"Test cases": "Test cases",
"The probe could not run": "The probe could not run",
@@ -558,17 +647,22 @@
"Trigger scheduled": "scheduled re-check",
"Trigger unused": "first sweep",
"Unpriced models": "Unpriced models",
+ "Unsaid": "Unsaid",
"Unstable": "Unstable",
"Upstream API": "Upstream API",
"User prompt": "User prompt",
+ "Varies": "Varies",
"Vendor detail": "Carries {names}.",
"Vendor headers": "Vendor headers",
+ "Vendor relayed": "This host is not {vendor}'s own API, and what it sells
are {vendor}'s model names. Relaying is not a fault in itself, but nothing on
this chain can be checked against the vendor it is selling.",
"Vendor undocumented": "Not asked: this endpoint is not a vendor's own, so
there is no documented header list to hold it to.",
+ "Vendors hint": "Vendor keys — openai, anthropic — for a question only
those vendors' own APIs document an answer to. Empty asks it of every model.",
"Warn outside": "Warn outside",
"Weight": "Weight",
"Weight hint": "What this case is worth in the score, relative to the
other cases measured on the same run.",
"What this cannot tell you": "These are the upstream's own numbers. They
show what it reported, not what it ran.",
- "What this sends": "What this sends"
+ "What this sends": "What this sends",
+ "Wrong": "Wrong"
},
"general": {
"Action": "Action",
@@ -599,8 +693,10 @@
"Failed to get data": "Failed to get data",
"Failed to save": "Failed to save",
"Failed to sign out": "Failed to sign out",
+ "First run notice body": "Casbin Gateway routes your coding agents'
requests through providers you control, checks whether the model behind an API
key is really what it claims to be, and records what your agents send and
receive.\nEverything runs on this machine: nothing is uploaded anywhere.",
"GitHub could not be reached, if this machine needs a proxy to reach the
internet, set one and try again": "GitHub could not be reached, if this machine
needs a proxy to reach the internet, set one and try again",
"Go to writable demo site?": "Go to writable demo site?",
+ "Got it": "Got it",
"Home": "Home",
"ID": "ID",
"Installing": "Installing",
@@ -647,6 +743,7 @@
"Update complete, reloading": "Update complete, reloading",
"Update now": "Update now",
"Version": "Version",
+ "Welcome to Casbin Gateway": "Welcome to Casbin Gateway",
"{total} in total": "{total} in total"
},
"llm": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f7c18d8..c442bf0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -39,7 +39,14 @@
"All event types": "全部事件类型",
"All outcomes": "全部结果",
"Any model": "任意模型",
+ "Any other MCP server": "其他所有 MCP 服务",
+ "Any other command tool": "其他所有执行命令的工具",
+ "Any other tool that goes online": "其他所有联网工具",
+ "Any other tool that reads": "其他所有读取类工具",
+ "Any other tool that writes": "其他所有写入类工具",
"Any provider": "任意供应商",
+ "Apply a patch": "应用补丁",
+ "Ask the user a question": "向用户提问",
"Auto refresh": "自动刷新",
"Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的供应商,与请求里的模型名无关。",
"Bound provider cooling down": "绑定的供应商已暂时移出轮转,请求将走备用供应商",
@@ -51,8 +58,8 @@
"Capture sign-in hint": "让浏览器返回的下一个登录链接打开这个副本。否则链接只会打开 Agent
自己注册的那个副本,这个副本永远登录不上。",
"Casbin configuration": "Casbin 配置",
"Casbin configuration copied": "Casbin 配置已复制到剪贴板",
- "Casbin hint": "上面的开关会编译成这份策略,网关的每个请求都由 casbin 按它来判定。",
- "Change files": "修改文件",
+ "Casbin hint": "上面的开关会编译成这份策略,第一条匹配上的规则说了算。每个转发的请求都要过一遍 Casbin。",
+ "Changing the project": "修改项目",
"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
继续用它已有的登录。",
@@ -62,12 +69,19 @@
"Configuration written": "配置已写入",
"Cooling down": "冷却中",
"Copy path": "复制路径",
+ "Create a directory": "新建目录",
+ "Create or overwrite a file": "新建或覆盖文件",
+ "Delete a file": "删除文件",
"Detail": "详情",
"Details": "详情",
"Direct": "直连",
"Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启
Agent 才能生效。",
"Direct mode permission hint": "该智能体直连供应商,请求不经过网关,这里的规则不会生效。请把它切回经网关转发。",
+ "Drive a browser": "操作浏览器",
"Duration": "耗时",
+ "Edit a file": "编辑文件",
+ "Edit a notebook": "编辑 Notebook",
+ "Edit several places at once": "一次编辑多处",
"Empty message": "空消息",
"Enable": "启用",
"Enforce permissions": "启用权限控制",
@@ -75,7 +89,7 @@
"Enforced hint": "该智能体经网关转发的每个请求,都会按下面的规则检查。",
"Event": "事件",
"Extra rules": "额外规则",
- "Extra rules hint": "每行一条策略,格式为 \"sub, obj, act, eft\"。它们会追加到上面的策略中,deny
优先。",
+ "Extra rules hint": "每行一条策略,格式为 \"sub, obj, act,
eft\"。它们排在开关生成的规则之前,用来表达开关表达不了的例外。",
"Failed to add the instance": "添加实例失败",
"Failed to get agent records": "获取 Agent 记录失败",
"Failed to get agent sessions": "获取 Agent 会话失败",
@@ -89,7 +103,9 @@
"Failed to update agent provider": "更新 Agent 供应商失败",
"Failed to write the agent configuration": "写入 Agent 配置失败",
"Fallback hint": "当绑定的供应商无法响应时,按所选顺序依次尝试的备用供应商。",
+ "Fetch a URL": "抓取网址",
"Filter": "筛选",
+ "Find files by name": "按文件名查找",
"First activity": "首次活动",
"From the transcript": "来自会话记录",
"Gateway": "网关",
@@ -110,8 +126,13 @@
"Instance removed": "已移除实例",
"Instances": "实例",
"Instances hint": "该 Agent 的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
+ "Keep a task list": "维护任务清单",
"Last activity": "最后活动",
"Last {count} records": "最近 {count} 条记录",
+ "Leave plan mode": "退出计划模式",
+ "List a directory": "列出目录",
+ "Look at an image": "查看图片",
+ "MCP servers": "MCP 服务",
"MCP target": "MCP 目标",
"Model": "模型",
"Model list hint": "名称可以用 \"*\" 结尾,例如 \"claude-opus-*\" 覆盖整个系列。",
@@ -120,6 +141,8 @@
"Monitoring disabled": "已关闭监控",
"Monitoring enabled": "已开启监控",
"Monitoring, no activity yet": "监控中,暂无活动",
+ "Move or rename a file": "移动或重命名文件",
+ "No agent found": "没有找到智能体",
"No agent records yet - patch an agent to start collecting them":
"还没有活动记录,开启某个智能体的监控后才会开始收集",
"No agent sessions yet": "还没有会话",
"No agent sessions yet - patch an agent to start collecting them":
"还没有会话,开启某个智能体的监控后才会开始收集",
@@ -150,7 +173,10 @@
"Path copied to clipboard": "路径已复制到剪贴板",
"Payload": "负载",
"Permissions": "权限",
+ "Permissions empty hint": "这台机器上没有发现智能体,暂时还没有可以设规则的对象。",
+ "Permissions page hint": "这台机器上每个智能体能向网关要什么。开关由你来设,每个转发的请求都由 Casbin 依此判定。",
"Permissions saved": "权限已保存",
+ "Planning and delegation": "规划与委派",
"Preview": "预览",
"Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
"Processes": "进程",
@@ -159,11 +185,15 @@
"Provider enabled": "已启用供应商",
"Provider saved": "供应商已保存",
"Provider speaks another API": "该 Agent 使用 {protocol}
API,而绑定的供应商不支持。直连模式要求两者使用同一种 API:请改选其他供应商,或将该 Agent 改为经网关转发(网关会自动转换)。",
- "Reach the internet": "访问网络",
- "Read files": "读取文件",
+ "Read a file": "读取文件",
+ "Read a notebook": "读取 Notebook",
+ "Read a running command's output": "读取运行中命令的输出",
"Read from the agent's own logs": "读自 agent 自己的日志",
+ "Read many files at once": "一次读取多个文件",
+ "Reading the project": "读取项目",
"Records": "记录数",
"Records are kept in memory only and are lost when Gateway restarts":
"记录仅保存在内存中,Gateway 重启后会丢失",
+ "Remember something": "记住信息",
"Remove instance": "移除实例",
"Remove instance hint": "Gateway 将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
"Reported from": "上报来源",
@@ -174,24 +204,33 @@
"Rewrite configuration": "重新写入配置",
"Route through Gateway": "经由 Gateway 路由",
"Run Status": "运行状态",
- "Run commands": "执行命令",
+ "Run a command": "执行命令",
+ "Run a skill": "运行技能",
+ "Run a slash command": "执行斜杠命令",
"Running": "运行中",
"Running in a container": "Gateway 正跑在容器里",
"Running in a container detail": "Agent 是靠读取 Gateway
所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把 Gateway 装到宿主机上才能看到装在那里的 Agent。Providers
和 LLM Records 在容器里照常可用。",
"Scan": "重新扫描",
+ "Search inside files": "在文件内容里搜索",
+ "Search the codebase by meaning": "按语义搜索代码库",
+ "Search the web": "搜索网页",
"Session": "会话",
"Session title": "会话标题",
"Source": "来源",
"Start": "启动",
+ "Start a sub-agent": "启动子智能体",
"Start app hint": "在运行 Gateway 的机器上打开该应用。",
"Start console hint": "在运行 Gateway 的机器上打开一个控制台窗口来运行该命令行。",
"State directory": "状态目录",
"Still calling Gateway": "该 Agent
已设为使用自带模型,但它的配置仍指向网关,所以每个请求都会失败。请恢复它的配置。",
"Stop": "停止",
+ "Stop a running command": "停止运行中的命令",
"Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
"Stopped": "未运行",
"Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。",
"Target / Model": "目标 / 模型",
+ "Terminal": "终端",
+ "The internet": "访问网络",
"Thinking": "思考",
"This transcript holds no conversation": "该会话记录里没有对话内容",
"Time": "时间",
@@ -199,7 +238,7 @@
"Tool": "工具",
"Tool call": "工具调用",
"Tool error": "工具报错",
- "Tool hint": "关掉的工具会在请求发给模型之前被移除,智能体拿不到,也就无法调用。",
+ "Tool hint": "关掉的开关会在请求发给模型之前把对应的工具移除,智能体拿不到,也就无法调用。点分组标题上的开关可以整组一起设。",
"Tool result": "工具返回",
"Tool use ID": "工具调用 ID",
"Transcript truncated": "这个会话有部分内容太大,没能全部展示出来",
@@ -213,7 +252,6 @@
"Upgrade {agent}?": "升级 {agent}?",
"Upgrading": "升级中",
"Usage": "用量",
- "Use MCP servers": "使用 MCP 服务",
"User": "用户",
"Version": "版本",
"View Records": "查看活动",
@@ -224,7 +262,10 @@
"Write confirm hint": "Gateway 会把所选供应商以该 Agent
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
"Write the configuration of {agent}?": "写入 {agent} 的配置?",
"Written by Gateway": "已由 Gateway 写入",
+ "allowed": "项已允许",
+ "blocked": "项被拦截",
"opencode config hint": "opencode 不从环境变量读取 base URL:端点是
~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用
@ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。",
+ "switches": "个开关",
"{count} messages": "{count} 条消息"
},
"agentConfig": {
@@ -410,11 +451,17 @@
"{files} files": "{files} 个文件"
},
"audit": {
+ "Accepted answers": "算答对的答案",
+ "Accepted answers hint": "命中任意一条就算答对;留空则只要有回答就算通过。",
"Alert outside": "超出则报警",
"Allowed drift": "允许的漂移",
"Allowed drift hint": "两个逐字节相同的请求,计费相差多少以内还算正常,超过就是编出来的。",
+ "Answer empty": "什么都没答出来,多半是回答长度不够或者全被思考占满了,不算答错。",
+ "Answer forbidden": "回答里出现了这道题明确排除的「{forbidden}」:{answer}",
"Answer length": "回答长度",
"Answer length hint": "请求里的 max_tokens。要测的是信封,不是文笔。",
+ "Answer right": "回答:{answer}",
+ "Answer wrong": "回答是「{answer}」,而这道题的答案是 {expected}。",
"Authenticity": "真伪检测",
"Authenticity description": "每个 API Key
背后的服务是否就是它所声称的那个。每个供应商都会被自动探测,无需手动触发,并按一套公开的测试用例打分:每条用例问什么、怎么判定、占多少权重都列在下面,而且都可以修改。",
"Billing detail": "实际发出约 {estimate} token,被计了 {billed}。四字符折一 token
只是粗估,差得多才说明问题。",
@@ -436,6 +483,8 @@
"Channel audit": "渠道体检",
"Check": "检查项",
"Check hint": "由哪个引擎来跑:决定发什么请求、怎么读答案。",
+ "Compared as": "比对方式",
+ "Compared as hint": "包含:出现在回答里即可;完全一致:去掉大小写和标点后整句相同;正则:按正则表达式匹配。",
"Complete": "齐全",
"Custom case": "自定义",
"Defaults restored": "内置用例已恢复",
@@ -443,10 +492,15 @@
"Delete custom case detail": "这条用例是在这里写的,删除后无法恢复。",
"Delete this test case?": "删除这条用例?",
"Different": "不一致",
+ "Documented parameter": "文档参数",
+ "Dropped": "被丢弃",
+ "Echoed": "只是回声",
"Edit test case": "编辑用例",
"Every model has a rate": "该渠道服务过的模型都能查到公开价格。",
"Expected events": "应有的事件",
"Expected events hint": "这套 API 文档规定的事件名。缺哪个,哪个就是问题。",
+ "Extra hint": "一段 JSON,会合并进请求体,用来发出这道题要考的那个参数。",
+ "Extra request fields": "附加请求字段",
"Failed attempts": "失败请求",
"Failed cases": "{count} 条不通过",
"Failed over": "被切走",
@@ -454,6 +508,7 @@
"Failures detail": "{sample} 次尝试中有 {failed} 次没有返回结果。",
"First byte": "首字节耗时 {ttft}。",
"Flagged cases": "{count} 条存疑",
+ "Found": "有",
"Grade": "等级",
"Grade A": "与官方文档一致",
"Grade A detail": "所有可测的用例都按厂商自己 API 文档所写的方式作答。",
@@ -472,14 +527,22 @@
"Headers looked for": "查找的响应头",
"Headers required": "至少要有几个",
"Held": "接住了",
+ "Hidden found": "请求里并没有系统提示,它却答出了别的内容:{answer}",
+ "Hidden instructions": "隐藏指令",
+ "Hidden none": "这次请求上面没有本网关之外的任何东西。",
+ "Honored": "生效",
"Identity alias": "请求的是 {asked},回答的是 {answered},而这正是该厂商在文档里写明这个名字指向的模型。",
"Identity different": "请求的是 {asked},返回里写的是 {answered}。",
"Identity has no request": "这条用例不单独发请求:它读的是本次探测第一个回答里的模型名。",
"Identity ok": "返回 {answered}。把名字原样回显是最容易做到的事,所以这只是底线,不是证明。",
"Identity reads": "本次探测第一个回答里的模型名。",
+ "Identity unverified":
"回来的模型名就是发出去的那个({answered}),而这个名字不在任何已知厂商的型号表里——回声而已,证明不了什么。",
"Last seen": "最后一次",
"Latency detail": "最慢的 5% 从 {p95} 起,是中位数的 {ratio} 倍。",
"Lost": "没接住",
+ "Match contains": "包含",
+ "Match exact": "完全一致",
+ "Match regex": "正则",
"Matches": "一致",
"Measuring now": "正在测量。每个供应商加入后不久就会被探测一次。",
"Method": "判定方法",
@@ -501,9 +564,15 @@
"Nothing to audit yet detail": "体检读的是代理本来就已经留下的记录。把某个 Agent
指向某个渠道,它的第一批请求就会出现在这里。",
"Nothing was relayed in this window": "这段时间内没有转发过请求。",
"Only the newest records were read": "这段时间的记录条数超过了单次体检的上限,只统计了最新的 {count}
条。",
+ "Only these vendors": "只问这些厂商",
"Order": "顺序",
"Order hint": "数值越小越先跑、越先显示。",
"Page description": "从这里看每个上游是什么样子,两个角度都看。流量侧读的是 Gateway
本来就记下的记录,不花钱。探测则直接向上游提问,跑那套公开的测试用例——答案对不对,厂商自己的 API
文档说了算——会花掉该供应商一点点额度。每个数字都标明是在多大样本上算出来的:几次请求上的比例算不上结论。",
+ "Parameter dropped": "参数收下了却没照做:回答是「{answer}」,本应是 {wanted}。",
+ "Parameter honored": "参数照文档生效了。",
+ "Parameter ignored": "请求被 200 收下了,回答里却没有 {field}——这个参数根本没有被传下去。",
+ "Parameter rejected": "上游明确拒绝了这个参数,那是厂商没实现,不是通道的问题,因此不计入分数:{reason}",
+ "Parameter shape": "{field} 的取值是 {value},和 {wanted} 对不上。",
"Pricing detail": "{sample} 次成功请求中,有 {unpriced} 次用的模型在任何价格表里都查不到。",
"Probe": "主动探测",
"Probe again": "重新探测",
@@ -520,24 +589,44 @@
"Providers alerting": "{count} 个有不通过的用例",
"Providers unmeasured": "{count} 个尚未测出",
"Question": "问题",
+ "Question failed": "这一问没能问出去:{reason}",
"Question hint": "这条用例向上游问的是什么,一句话。",
+ "Question prompt hint": "真正发给上游的那句话。",
"Read from {count} records": "读自 {count} 条记录",
"Reads": "读取",
"Recording is off detail": "llmRecordMode 为 \"off\"
时体检没有数据可读。这里的每项检查只需要元数据,不必保存提示词内容。",
+ "Rejected answers": "算答错的答案",
+ "Rejected answers hint": "回答里只要出现其中任意一条,这道题就算没过。",
"Removed": "已删除",
+ "Repeat answers": "同样的请求答出了不同的内容:「{first}」和「{second}」。采样本来就允许不同,仅作参考。",
+ "Repeat models": "同样的请求,回来的模型名却不一样:{names}",
+ "Repeat same": "{count} 次完全相同的请求,回来的模型名和输入 token 数都一样。",
+ "Repeat tokens": "同样的字节,被算成了不同的输入 token 数:{counts}。分词器不同,模型就不是同一个。",
+ "Repeated request": "重复请求",
"Report": "评分报告",
+ "Requests sent": "发送次数",
+ "Requests sent hint": "同一个请求发几次,2 到 6 次,默认 3 次。",
+ "Required answer fields": "回答里必须有的字段",
+ "Required hint": "用点号写路径,例如 choices.0.logprobs;后面加 =正则
还可以要求取值的样子。字段缺失就说明这个参数被悄悄丢掉了。",
"Response time": "响应耗时",
"Restore defaults": "恢复默认",
"Restore detail": "所有内置用例都会恢复成出厂的样子,被删掉的也会回来。你自己写的用例不受影响。",
"Restore the shipped suite?": "恢复内置用例?",
+ "Right": "答对",
"Run this case": "运行这条用例",
"Score from cases": "{total} 条用例中测得 {measured} 条",
"Second request after": "第二次请求间隔",
"See the evidence": "查看证据",
+ "Self match": "它说自己出自 {vendor}:{answer}",
+ "Self other": "卖的是 {vendor} 的模型,它却说自己出自 {other}:{answer}",
+ "Self silent": "它没说自己出自 {vendor},也没说是别家:{answer}",
+ "Self undocumented": "这个模型名不属于任何一家已知厂商的型号表,它的回答也就没有可对照的对象。",
+ "Self-reported maker": "自述来历",
"Stream complete": "该 API 文档里的事件全部按顺序到齐。",
"Stream missing": "缺失或为空:{items}。",
"Stream shape": "流式结构",
"System prompt": "System 提示词",
+ "Test bank": "题库",
"Test case saved": "用例已保存",
"Test cases": "测试用例",
"The probe could not run": "探测无法执行",
@@ -558,26 +647,82 @@
"Trigger scheduled": "定期复检",
"Trigger unused": "首次巡检",
"Unpriced models": "无价模型",
+ "Unsaid": "没说",
"Unstable": "不稳定",
"Upstream API": "上游 API",
"User prompt": "User 提示词",
+ "Varies": "不一致",
"Vendor detail": "带有 {names}。",
"Vendor headers": "厂商响应头",
+ "Vendor relayed": "这个域名不是 {vendor} 自己的 API,卖的却是 {vendor}
的模型名。中转本身不是错,但这条链路上的东西都没法再和厂商自己的接口对上了。",
"Vendor undocumented": "未发问:这个地址不是厂商自己的域名,没有可供比对的响应头清单。",
+ "Vendors hint": "写厂商 key(openai、anthropic 等),用于只有该厂商自己的 API
才有文档可依的题。留空则对所有模型都问。",
"Warn outside": "超出则存疑",
"Weight": "权重",
"Weight hint": "这条用例在评分中占的分量,与同一次探测中其他被测用例相比。",
"What this cannot tell you": "这些是上游自己报的数。它们说明的是上游报了什么,而不是上游实际跑了什么。",
"What this sends": "实际发送的内容",
+ "Wrong": "答错",
"case.billing-identical-requests.displayName": "Token 计费",
"case.billing-identical-requests.method":
"缓存那两次请求的字节完全相同,输入计数就必须一致;对不上的数不是数出来的,是编出来的。之后再把计数和实际发出的字节量比一比,按四个字符一个 token
粗算,所以只有差得离谱才算问题。",
"case.billing-identical-requests.question": "两个逐字节相同的请求计费是否一致,且与实际发出的量相称?",
"case.cache-identical-prefix.displayName": "提示词缓存",
"case.cache-identical-prefix.method":
"同一段长提示词逐字节相同地发两次,间隔一秒半。真缓存会在第一次写入、第二次读回。两个计数器都是零,就说明整段前缀两次都按新输入计费了。",
"case.cache-identical-prefix.question": "缓存是真的,还是把同一段前缀当新输入收了两遍钱?",
+ "case.feature-choice-count.displayName": "一次要两个答案",
+ "case.feature-choice-count.method": "n 是 chat completions API
的一部分。有几家厂商没实现并且会直说,那记录为拒绝、不计分;这一题找的是另一种:收下了 n,照常收费,却只回一个 choice——这个参数根本没被传下去。",
+ "case.feature-choice-count.question": "要了两个补全,回来的是两个吗?",
+ "case.feature-logprobs.displayName": "token 概率",
+ "case.feature-logprobs.method": "logprobs 是 chat completions API
的一部分,而这些概率只有真正生成这些 token 的东西才产得出来。没实现它的厂商会直接拒绝请求,那只记录为拒绝、不计分;把这次调用翻译成别的 API
的后端则会照样返回 200,只是那个字段不见了。",
+ "case.feature-logprobs.question": "要了 token 概率,回答里带回来了吗?",
+ "case.feature-openai-completion-id.displayName": "补全 id 的样子",
+ "case.feature-openai-completion-id.method": "OpenAI 自家 API 返回的每个补全都以
chatcmpl- 开头;用自己另一套 API 作答的中转带回来的是那套 API 的 id。这是指纹,不是 OpenAI
承诺过的格式,所以默认关闭:如果别家的 id 对你有意义,再打开它。",
+ "case.feature-openai-completion-id.question": "OpenAI 的补全,id 是 OpenAI 自家
API 那个样子吗?",
+ "case.feature-openai-fingerprint.displayName": "后端指纹",
+ "case.feature-openai-fingerprint.method": "system_fingerprint 是 OpenAI 自家
chat completions 响应里写明的字段,也正是不是 OpenAI 的后端填不出来的那种字段。只对 OpenAI
自家型号表里的模型问这一题,因为写下它的是 OpenAI 自己的文档。",
+ "case.feature-openai-fingerprint.question": "OpenAI 的模型有没有带上 OpenAI
自己写在文档里的后端指纹?",
+ "case.feature-stop-anthropic.displayName": "停止序列(Anthropic)",
+ "case.feature-stop-anthropic.method": "同一个问题在这套 API 里的写法:stop_sequences
会把回答切在 B 之前,所以只允许回来一个 A。后面还有字,就说明这个参数在链路的某一段被丢掉了。",
+ "case.feature-stop-anthropic.question": "该停的地方停住了吗?",
+ "case.feature-stop-openai.displayName": "停止序列(OpenAI)",
+ "case.feature-stop-openai.method": "让模型写 A B C,请求里要求在 B 处停下,所以回答只能是一个
A。停止序列后面还有字,就说明这个参数在链路的某一段被丢掉了。有些推理模型对停止序列执行得比较松,所以这只算提示,不算实锤。",
+ "case.feature-stop-openai.question": "该停的地方停住了吗?",
+ "case.hidden-system-prompt.displayName": "隐藏指令",
+ "case.hidden-system-prompt.method":
"这次请求没有带任何系统提示,上面本该什么都没有。让上游冒充别人的模型,通常靠的就是在每个请求前面注入一段提示;被要求复述上面的内容时,模型要么念出来,要么拒绝。两种都不能单独定罪,所以这一题永远不会报警——它只把答到的原话放在报告上,供人自己判断。",
+ "case.hidden-system-prompt.question": "请求上面是不是被塞了本网关没有发过的东西?",
"case.identity-model-name.displayName": "模型身份",
- "case.identity-model-name.method": "第一个请求点名一个模型。所有补全 API
都在回答里写明实际执行的模型,同一系列的版本号或日期后缀算匹配。如果请求的名字本就是厂商声明会变的别名——比如 deepseek-chat,或任何以
-latest
结尾的——那回答只需要仍是该厂商的模型,这正是厂商自己文档里写的。除此以外的不一致就是硬证据;而名字对上了并说明不了太多——把名字原样拄回来是最容易做到的事。",
- "case.identity-model-name.question": "作答的模型自报的名字,是不是请求里点名的那个?",
+ "case.identity-model-name.method": "第一个请求点名了一个模型。所有补全 API
都会在回答里带上真正运行的模型名,同一系列后面多一个版本号或日期算匹配。如果点的是厂商自己声明会漂移的名字——deepseek-chat、以 -latest
结尾的——那只要求回答仍是这家厂商的模型,这本来就是厂商说好的。对不上就是实锤。对得上却远没有看上去那么值钱:那个字段是谁在答谁来写,所以在不是厂商自己域名的接口上,它只是把你发出去的名字念回来而已,因此只按半题计分。",
+ "case.identity-model-name.question": "请求里点名要哪个模型,回答里说自己是哪个模型?",
+ "case.knowledge-arithmetic.displayName": "不借工具的算术",
+ "case.knowledge-arithmetic.method": "两个四位数一次算完是一道能力门槛:答案是
32606167,进位撑不住的模型会写出一个很接近的数。",
+ "case.knowledge-arithmetic.question": "3947 乘以 8261 等于多少?",
+ "case.knowledge-atomic-number.displayName": "长尾知识",
+ "case.knowledge-atomic-number.method": "一个足够靠后的事实,小模型只能猜。答案是 74。",
+ "case.knowledge-atomic-number.question": "钨的原子序数是多少?",
+ "case.knowledge-chinese-author.displayName": "中文长尾知识",
+ "case.knowledge-chinese-author.method":
"以英文语料为主训练出来的模型会答成另一位唐代诗人。答案是王勃。用中文问,同时也在看回答会不会跟着问题的语言走。",
+ "case.knowledge-chinese-author.question": "《滕王阁序》的作者是谁?",
+ "case.knowledge-day-of-week.displayName": "不查日历算星期",
+ "case.knowledge-day-of-week.method": "100 天是 14 周零 2
天,所以答案是星期五。这要两步,冒名顶替的小模型往往只走第一步。",
+ "case.knowledge-day-of-week.question": "今天是星期三,100 天后是星期几?",
+ "case.knowledge-decimal-order.displayName": "哪个小数更大",
+ "case.knowledge-decimal-order.method": "当成版本号读,9.11
是后出的那个,见过的更新日志比算术多的模型就会这么答。答案是 9.9。",
+ "case.knowledge-decimal-order.question": "9.11 和 9.9 哪个更大?",
+ "case.knowledge-instruction-exact.displayName": "照字面执行指令",
+ "case.knowledge-instruction-exact.method":
"指令没有留余地:三个字符,不加标点,不加句子。冒名顶替的小模型会加上一句问候或者解释,这是最便宜的指令遵循测试。",
+ "case.knowledge-instruction-exact.question": "只要三个字符、不要别的,回来的还有别的吗?",
+ "case.knowledge-letter-count.displayName": "数字母",
+ "case.knowledge-letter-count.method":
"数一个词里的字母,要模型顶着自己的分词器去做,而不是靠背,所以特别能分出档次。答案是 3。",
+ "case.knowledge-letter-count.question": "单词 strawberry 里有几个字母 r?",
+ "case.knowledge-reverse-string.displayName": "逐字倒序",
+ "case.knowledge-reverse-string.method": "倒写一个词要用到字母本身而不是
token,和数字母是同一个弱点的两面。答案是 eborp。",
+ "case.knowledge-reverse-string.question": "把 probe 这个词倒着写是什么?",
+ "case.repeat-same-backend.displayName": "一个后端还是一池后端",
+ "case.repeat-same-backend.method":
"同一个请求发三次。一个把一部分流量交给卖出的模型、另一部分交给便宜货的中转,有两样东西稳不住:回答上的模型名,以及输入 token
数——后者是分词器的性质,也就是模型本身的性质。任何一样在变都是实锤。回答内容不同只算提示,采样本来就允许不同。",
+ "case.repeat-same-backend.question": "同一个请求发好几次,回来的是同一个模型吗?",
+ "case.selfid-vendor.displayName": "自述来历",
+ "case.selfid-vendor.method":
"回答里的模型名是上游想写什么就写什么。但模型说自己是谁不是——那是真正在跑的那份权重说出来的。卖的是这家的模型,它却说自己出自另一家,正是这套检测存在的意义;两家都不说只算提示,因为一段系统提示就能让模型闭嘴,却不一定让它说谎。只有当卖出的名字属于本程序认识的厂商时才会问这一题。",
+ "case.selfid-vendor.question": "问它是谁做的,它说的厂商和卖它的那个名字对得上吗?",
"case.stream-events-anthropic.displayName": "流式结构(Anthropic)",
"case.stream-events-anthropic.method": "同样的请求开着流式再发一次。把别的 API 翻译成这套 API
的后端,往往是文字还没露馅、信封先错了:开头和结尾的事件不见了,或者 message_start 里没有输入计数,因为那时它还什么都没数。",
"case.stream-events-anthropic.question": "事件流是否按文档规定的顺序带齐了每一个事件?",
@@ -620,8 +765,10 @@
"Failed to get data": "获取数据失败",
"Failed to save": "保存失败",
"Failed to sign out": "退出登录失败",
+ "First run notice body": "Casbin Gateway 会把你的编码代理请求路由到你自己掌控的服务商,检查 API key
背后的模型是否真如其所声称的那样,并记录代理收发的内容。\n一切都在本机运行,不会上传到任何地方。",
"GitHub could not be reached, if this machine needs a proxy to reach the
internet, set one and try again": "无法连接 GitHub。如果本机需要代理才能访问外网,请先配置代理后重试。",
"Go to writable demo site?": "跳转至可写演示站点?",
+ "Got it": "知道了",
"Home": "首页",
"ID": "ID",
"Installing": "正在安装",
@@ -668,6 +815,7 @@
"Update complete, reloading": "升级完成,正在重新加载",
"Update now": "立即升级",
"Version": "版本",
+ "Welcome to Casbin Gateway": "欢迎使用 Casbin Gateway",
"{total} in total": "共 {total} 条"
},
"llm": {
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 5045af1..7a9837c 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -23,6 +23,7 @@ import {
Plug,
Settings,
ShieldCheck,
+ ShieldHalf,
Table2,
type LucideIcon,
} from "lucide-react";
@@ -75,6 +76,7 @@ export const navGroups: NavGroup[] = [
adminOnly: true,
},
{key: "/agent-records", label: "agent:Agent Records", icon: FileSearch,
path: "/agent-records", adminOnly: true},
+ {key: "/permissions", label: "agent:Permissions", icon: ShieldHalf, path:
"/permissions", adminOnly: true},
{key: "/providers", label: "provider:Providers", icon: Plug, path:
"/providers"},
{key: "/authenticity", label: "audit:Authenticity", icon: ShieldCheck, path:
"/authenticity", adminOnly: true},
{key: "/llm-records", label: "llm:LLM Records", icon: Logs, path:
"/llm-records", adminOnly: true},
diff --git a/web/src/pages/PermissionsPage.tsx
b/web/src/pages/PermissionsPage.tsx
new file mode 100644
index 0000000..447efdc
--- /dev/null
+++ b/web/src/pages/PermissionsPage.tsx
@@ -0,0 +1,190 @@
+// 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 {useSearchParams} from "react-router-dom";
+import {Bot, RefreshCw} from "lucide-react";
+import i18next from "i18next";
+
+import * as PermissionBackend from "@/backend/PermissionBackend";
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {PermissionCard} from "@/components/PermissionCard";
+import {EmptyState} from "@/components/shared/empty-state";
+import {AiDots} from "@/components/shared/loading";
+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 {useAgents} from "@/lib/agents";
+import {cn} from "@/lib/utils";
+import type {Account, Agent, AgentPermission, Provider} from "@/types";
+
+/** One agent in the rail, with what it is held to right now. */
+function AgentRow({
+ agent,
+ permission,
+ active,
+ onSelect,
+}: {
+ agent: Agent;
+ permission?: AgentPermission;
+ active: boolean;
+ onSelect: () => void;
+}) {
+ const blocked = Object.values(permission?.tools ?? {}).filter(allowed =>
!allowed).length;
+ const restricted = permission?.enabled === true;
+
+ return (
+ <button
+ type="button"
+ onClick={onSelect}
+ className={cn(
+ "flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2
text-left text-sm transition-colors",
+ active ? "border-primary bg-primary/10" : "hover:bg-accent",
+ )}
+ >
+ <AgentIcon
+ agent={agent.agentId || agent.name}
+ size={20}
+ fallback={<Bot className="h-5 w-5 text-muted-foreground" />}
+ />
+ <span className="min-w-0 flex-1 truncate">{agent.name}</span>
+ {!restricted ? (
+ <Badge variant="muted" className="shrink-0 font-normal">
+ {i18next.t("agent:Unrestricted")}
+ </Badge>
+ ) : blocked === 0 ? (
+ <Badge variant="muted" className="shrink-0 font-normal">
+ {i18next.t("agent:Enforced")}
+ </Badge>
+ ) : (
+ <Badge variant="success" className="shrink-0 font-normal">
+ {`${blocked} ${i18next.t("agent:blocked")}`}
+ </Badge>
+ )}
+ </button>
+ );
+}
+
+/**
+ * One page for what every agent on this machine may do. The rail is the
agents,
+ * the panel is the switches of the one picked, and both read the same rules
the
+ * proxy holds each relayed request to.
+ */
+export default function PermissionsPage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const {agents, scanned, scan} = useAgents(isAdmin);
+ const [permissions, setPermissions] = React.useState<AgentPermission[]>([]);
+ const [providers, setProviders] = React.useState<Provider[]>([]);
+
+ // The rail lists one row per agent, not per installation: the rules are
+ // stored per agent id, so two copies of one agent are held to the same ones.
+ const unique: Agent[] = [];
+ agents.forEach(agent => {
+ if (!unique.some(seen => seen.agentId === agent.agentId)) {
+ unique.push(agent);
+ }
+ });
+
+ const selectedId = searchParams.get("agent") ?? "";
+ const selected = unique.find(agent => agent.agentId === selectedId) ??
unique[0];
+
+ const load = React.useCallback(() => {
+ if (!isAdmin) {
+ return;
+ }
+ PermissionBackend.getAgentPermissions()
+ .then(res => setPermissions(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setPermissions([]));
+ }, [isAdmin]);
+
+ React.useEffect(load, [load]);
+
+ React.useEffect(() => {
+ if (!isAdmin) {
+ return;
+ }
+ ProviderBackend.getProviders(account.name)
+ .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setProviders([]));
+ }, [isAdmin, account.name]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const permissionOf = (agentId: string) =>
+ permissions.find(permission => permission.name === agentId);
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("agent:Permissions")}
+ description={i18next.t("agent:Permissions page hint")}
+ actions={
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ scan(true);
+ load();
+ }}
+ >
+ <RefreshCw />
+ {i18next.t("agent:Scan")}
+ </Button>
+ }
+ />
+
+ {!scanned ? (
+ <div className="flex justify-center p-10">
+ <AiDots size="small" />
+ </div>
+ ) : unique.length === 0 ? (
+ <EmptyState
+ icon={Bot}
+ title={i18next.t("agent:No agent found")}
+ description={i18next.t("agent:Permissions empty hint")}
+ />
+ ) : (
+ <div className="grid grid-cols-1 gap-4
lg:grid-cols-[260px_minmax(0,1fr)]">
+ <div className="flex flex-col gap-1.5">
+ {unique.map(agent => (
+ <AgentRow
+ key={agent.agentId}
+ agent={agent}
+ permission={permissionOf(agent.agentId)}
+ active={selected?.agentId === agent.agentId}
+ onSelect={() => setSearchParams({agent: agent.agentId})}
+ />
+ ))}
+ </div>
+
+ {selected ? (
+ <PermissionCard
+ key={selected.agentId}
+ agent={selected}
+ providers={providers}
+ className="self-start"
+ onSaved={load}
+ />
+ ) : null}
+ </div>
+ )}
+ </PageContainer>
+ );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index b385fc9..b2827f3 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -171,12 +171,22 @@ export interface Agent {
supportsInstances?: boolean;
}
-/** One group of tools the Permissions card draws a switch for. */
+/** One switch of the Permissions page: something an agent may be allowed to
do,
+ * and the tool names the agents call it by. The name is "<group>/<item>",
which
+ * is also the casbin object it is checked as. */
+export interface ToolItem {
+ name: string;
+ group: string;
+ /** Set for an item the page has no wording of its own for, such as one of
the
+ * agent's MCP servers. */
+ label?: string;
+ tools: string[];
+}
+
+/** One group of switches, which can also be set in one go. */
export interface ToolGroup {
name: string;
- /** A few of the tools that fall in the group, since every agent names its
own
- * differently. */
- examples: string[];
+ items: ToolItem[];
}
/** What one agent is allowed to ask the gateway for. Mirrors
@@ -820,6 +830,8 @@ export interface Setting {
backupIntervalHours: number;
backupRetention: number;
backupDir: string;
+
+ firstRunNoticeStatus: "pending" | "confirmed";
}
export type BackupMode = "auto" | "off";
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]