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 df9ae75 feat(permissions): add policy packs that judge each tool
call's arguments
df9ae75 is described below
commit df9ae751e50767db41502f6946961db88ad83b51
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 25 21:34:29 2026 +0800
feat(permissions): add policy packs that judge each tool call's arguments
---
README.md | 21 ++
README_zh.md | 21 ++
agenthook/decide.go | 40 +++
agenthook/hook.go | 2 +
agentpatch/hermes_observer.py | 18 +-
agentpatch/openclaw_plugin.js | 15 +-
agentpatch/opencode_plugin.js | 26 +-
controllers/agent.go | 12 +-
controllers/permission.go | 57 ++++-
object/permission.go | 30 ++-
object/permission_check.go | 62 ++++-
object/permission_guard.go | 446 +++++++++++++++++++++++++++++++++
object/permission_packs.go | 130 ++++++++++
routers/router.go | 2 +
web/src/backend/PermissionBackend.ts | 11 +-
web/src/components/PermissionCard.tsx | 113 +++++++--
web/src/components/PermissionPacks.tsx | 217 ++++++++++++++++
web/src/locales/en/data.json | 20 ++
web/src/locales/zh/data.json | 20 ++
web/src/pages/PermissionsPage.tsx | 52 ++--
web/src/types.ts | 11 +
21 files changed, 1255 insertions(+), 71 deletions(-)
diff --git a/README.md b/README.md
index 10e5ab1..708196a 100644
--- a/README.md
+++ b/README.md
@@ -321,6 +321,27 @@ The first rule that matches decides, which is what lets
one exception stand in f
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.
+#### Policy packs: what a tool call does, not only which tool it is
+
+A switch can take `Bash` away; it cannot let `Bash` run `go build` and refuse
`rm -rf`. **Policy packs** can. The hook Gateway installs in an agent sends the
arguments of every tool call along with its name, and a second Casbin policy
reads them: the command it runs, the files it touches, the hosts it reaches.
+
+| Pack | Refuses |
+| --- | --- |
+| No destructive commands | recursive deletes in every shell's spelling, `git
reset --hard`, `git clean -f`, force pushes, discarding every change,
formatting a disk, shutting down |
+| Protect secrets | `~/.ssh`, `~/.aws`, `~/.kube`, registry tokens, the
agents' own sign-ins, `.env`, `*.pem`, `*.key` — through a tool or through the
shell — and dumping the environment |
+| Only known hosts | any URL or `curl`/`wget` target that is not code hosting,
a package registry or its common mirrors, or this machine |
+| No publishing | `git push`, publishing a package or an image, GitHub
releases and pull requests, `kubectl`, `helm` and `terraform` changes |
+
+Each pack is one button to apply to every agent at the top of the Permissions
page, and one switch on each agent's card. An agent stores the name of the pack
rather than its lines, so a release that tightens a pack tightens it
everywhere. **Advanced** shows the guard policy they compile to and takes
guards of your own, which are checked before every pack:
+
+```
+host, *.mycorp.com, allow
+path, ~/work/secrets/**, deny
+command, \bdocker\s+system\s+prune\b, deny
+```
+
+Packs hold the agents whose hook runs before a tool call: Claude Code, Qwen
Code, Gemini CLI, Cursor, Windsurf, opencode, Hermes and OpenClaw. Codex and
Claude Desktop have no such hook, and the page lists them as not held.
+
### What the agents spend, including what never went through Gateway
An agent on its own subscription relays nothing through Gateway, and a request
that goes straight to the vendor
diff --git a/README_zh.md b/README_zh.md
index 4b2012a..de167fc 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -318,6 +318,27 @@ p, claude-code, tool:*, use, allow
规则管的是走代理的流量,所以直连供应商的 Agent(它自己的配置指向厂商而不是 Gateway)不受这些规则约束,页面上会直接提示。
+#### 策略包:看工具调用具体做什么,而不只是哪个工具
+
+开关能把`Bash`整个拿走,却没法让`Bash`能跑`go build`、不能跑`rm
-rf`。**策略包**可以。Gateway装在Agent里的钩子,会把每次工具调用的参数连同工具名一起发过来,再由第二份Casbin策略去读:它要跑什么命令、碰哪些文件、连哪些域名。
+
+| 策略包 | 拒绝什么 |
+| --- | --- |
+| 禁止破坏性命令 | 各种shell写法的递归删除、`git reset --hard`、`git clean
-f`、强制推送、一次丢弃所有改动、格式化磁盘、关机 |
+| 保护密钥文件 |
`~/.ssh`、`~/.aws`、`~/.kube`、包仓库Token、各Agent自己的登录凭据、`.env`、`*.pem`、`*.key`,无论用工具读还是在终端里读,以及打印全部环境变量
|
+| 只允许访问白名单域名 | 不属于代码托管、包仓库及其常用镜像或本机的任何网址和`curl`/`wget`目标 |
+| 禁止发布和部署 | `git push`、发布包或镜像、GitHub发布和PR、`kubectl`、`helm`、`terraform`的变更 |
+
+每个策略包在Permissions页顶部都有一个按钮,一键应用到所有Agent;每个Agent的卡片上也各有一个开关。Agent存的是策略包的名字而不是里面的规则,所以以后的版本收紧某个策略包,所有地方都会跟着收紧。**Advanced**里能看到它们编译出的守卫策略,也可以自己加守卫规则,这些规则排在所有策略包之前:
+
+```
+host, *.mycorp.com, allow
+path, ~/work/secrets/**, deny
+command, \bdocker\s+system\s+prune\b, deny
+```
+
+策略包管得住的,是在工具调用前会触发钩子的Agent:Claude Code、Qwen Code、Gemini
CLI、Cursor、Windsurf、opencode、Hermes和OpenClaw。Codex和Claude
Desktop没有这种钩子,页面上会把它们列为管不到。
+
### 每个 Agent 花了多少,包括没走 Gateway 的那部分
用自己订阅登录的 Agent 不会往 Gateway 转发任何东西,直接打到厂商的请求在这里也不会留下记录 —— 但 Agent 自己会
diff --git a/agenthook/decide.go b/agenthook/decide.go
index bac63dd..855a2a9 100644
--- a/agenthook/decide.go
+++ b/agenthook/decide.go
@@ -124,6 +124,46 @@ func listed(values []string, name string) bool {
return false
}
+// bulkyArguments carry file contents, which guards never read.
+var bulkyArguments = map[string]bool{
+ "content": true, "contents": true, "new_string": true, "old_string":
true, "edits": true,
+ "new_source": true, "code_edit": true, "text": true, "patch": true,
"input": true,
+}
+
+func toolArguments(event map[string]any) map[string]any {
+ arguments := map[string]any{}
+ for _, key := range []string{"tool_input", "tool_info"} {
+ if nested, ok := event[key].(map[string]any); ok {
+ for name, value := range nested {
+ if !bulkyArguments[name] {
+ arguments[name] = value
+ }
+ }
+ }
+ }
+ for _, key := range []string{"command", "file_path", "url"} {
+ if value := stringValue(event[key]); value != "" {
+ arguments[key] = value
+ }
+ }
+ return arguments
+}
+
+func eventCwd(event map[string]any) string {
+ if cwd := firstString(event, "cwd"); cwd != "" {
+ return cwd
+ }
+ if info, ok := event["tool_info"].(map[string]any); ok {
+ if cwd := stringValue(info["cwd"]); cwd != "" {
+ return cwd
+ }
+ }
+ if roots, ok := event["workspace_roots"].([]any); ok && len(roots) > 0 {
+ return stringValue(roots[0])
+ }
+ return ""
+}
+
// allowed asks Gateway whether this tool call may go ahead. Anything that goes
// wrong - no endpoint, no answer, a body that will not parse - allows the
call:
// a hook that cannot get a verdict must not stop the agent from working.
diff --git a/agenthook/hook.go b/agenthook/hook.go
index 1a88142..0b47cb7 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -93,6 +93,8 @@ func Run(args []string, input io.Reader, output io.Writer,
errOutput io.Writer)
"tool": tool,
"sessionKey": firstString(event, "session_id",
"conversation_id", "trajectory_id"),
"toolUseId": stringValue(event["tool_use_id"]),
+ "input": toolArguments(event),
+ "cwd": eventCwd(event),
}
if allow, reason := allowed(*decisionURL, *ingestToken,
request); !allow {
code = writeDenial(output, errOutput, *agentID, reason)
diff --git a/agentpatch/hermes_observer.py b/agentpatch/hermes_observer.py
index 4ff6cf0..0cfa1b8 100644
--- a/agentpatch/hermes_observer.py
+++ b/agentpatch/hermes_observer.py
@@ -15,6 +15,7 @@ from __future__ import annotations
import atexit
import json
import logging
+import os
import queue
import re
import threading
@@ -442,7 +443,16 @@ def _tool_record(action: str, outcome: str, values:
dict[str, Any]) -> dict[str,
return {key: value for key, value in record.items() if value not in ("",
None)}
-def _decide(tool_name: str, session_id: str, tool_call_id: str) -> str:
+_BULKY_ARGUMENTS = {"content", "contents", "new_string", "old_string",
"edits", "patch"}
+
+
+def _guarded_arguments(args: Any) -> dict[str, Any]:
+ if not isinstance(args, dict):
+ return {}
+ return {key: value for key, value in args.items() if key not in
_BULKY_ARGUMENTS}
+
+
+def _decide(tool_name: str, session_id: str, tool_call_id: str, args: Any =
None) -> str:
"""Ask Gateway whether a tool call may go ahead, answering with the reason
it may not. Every failure answers "" - a plugin that cannot get a verdict
must not stop Hermes working."""
@@ -455,7 +465,10 @@ def _decide(tool_name: str, session_id: str, tool_call_id:
str) -> str:
"tool": tool_name,
"sessionKey": session_id,
"toolUseId": tool_call_id,
- }
+ "input": _guarded_arguments(args),
+ "cwd": os.getcwd(),
+ },
+ default=str,
).encode("utf-8")
request = urllib.request.Request(
_DECISION_URL, data=body, headers={"Content-Type":
"application/json"}
@@ -481,6 +494,7 @@ def _on_pre_tool_call(**values: Any) -> dict[str, Any] |
None:
_text(values.get("tool_name")),
_text(values.get("session_id")),
_text(values.get("tool_call_id")),
+ values.get("args"),
)
# Hermes vetoes the call on this directive and hands the message to the
# model; any other return value is ignored, which is what leaves every
diff --git a/agentpatch/openclaw_plugin.js b/agentpatch/openclaw_plugin.js
index cd554e5..396dfee 100644
--- a/agentpatch/openclaw_plugin.js
+++ b/agentpatch/openclaw_plugin.js
@@ -29,7 +29,7 @@ const DECIDE_TIMEOUT_MS = 2000;
// decide asks Gateway whether a tool call may go ahead, answering with the
// reason it may not. Everything that can go wrong answers "": a plugin that
// cannot get a verdict must not stop OpenClaw working.
-async function decide(toolName, sessionKey, toolCallId) {
+async function decide(toolName, sessionKey, toolCallId, params) {
if (!DECISION_URL || !toolName) return "";
try {
const controller = new AbortController();
@@ -44,6 +44,7 @@ async function decide(toolName, sessionKey, toolCallId) {
tool: toolName,
sessionKey: sessionKey || "",
toolUseId: toolCallId || "",
+ input: argumentsOf(params),
}),
signal: controller.signal,
}).finally(() => clearTimeout(timer));
@@ -55,6 +56,17 @@ async function decide(toolName, sessionKey, toolCallId) {
}
}
+// argumentsOf drops the file contents, which Gateway's guards never read.
+function argumentsOf(params) {
+ const kept = {};
+ if (!params || typeof params !== "object") return kept;
+ for (const [key, value] of Object.entries(params)) {
+ if (["content", "contents", "newText", "oldText", "new_string",
"old_string", "edits", "patch"].includes(key)) continue;
+ kept[key] = value;
+ }
+ return kept;
+}
+
// The plugin entry. definePluginEntry is not importable from outside the
// OpenClaw bundle, so this is the object it would have built: a permissive
// config schema, since nothing here is configured from openclaw.json.
@@ -74,6 +86,7 @@ export default {
event?.toolName || ctx?.toolName,
ctx?.sessionKey || event?.sessionKey,
event?.toolCallId || ctx?.toolCallId,
+ event?.params,
);
return reason ? { block: true, blockReason: reason } : {};
});
diff --git a/agentpatch/opencode_plugin.js b/agentpatch/opencode_plugin.js
index ebd7c09..ea5859d 100644
--- a/agentpatch/opencode_plugin.js
+++ b/agentpatch/opencode_plugin.js
@@ -100,7 +100,7 @@ function post(record) {
// decide asks Gateway whether a tool call may go ahead. Anything that goes
// wrong - Gateway down, a timeout, a body that will not parse - allows the
// call: a plugin that cannot get a verdict must not stop opencode working.
-async function decide(tool, sessionID, callID) {
+async function decide(tool, sessionID, callID, args, cwd) {
if (!DECISION_URL || !tool) return "";
try {
const controller = new AbortController();
@@ -110,7 +110,14 @@ async function decide(tool, sessionID, callID) {
const response = await fetch(DECISION_URL, {
method: "POST",
headers,
- body: JSON.stringify({ agent: AGENT, tool, sessionKey: sessionID || "",
toolUseId: callID || "" }),
+ body: JSON.stringify({
+ agent: AGENT,
+ tool,
+ sessionKey: sessionID || "",
+ toolUseId: callID || "",
+ input: argumentsOf(args),
+ cwd: cwd || "",
+ }),
signal: controller.signal,
}).finally(() => clearTimeout(timer));
const answer = await response.json();
@@ -121,6 +128,17 @@ async function decide(tool, sessionID, callID) {
}
}
+// argumentsOf drops the file contents, which Gateway's guards never read.
+function argumentsOf(args) {
+ const kept = {};
+ if (!args || typeof args !== "object") return kept;
+ for (const [key, value] of Object.entries(args)) {
+ if (["content", "contents", "newString", "oldString", "new_string",
"old_string", "edits", "patch"].includes(key)) continue;
+ kept[key] = value;
+ }
+ return kept;
+}
+
// promptText joins the text parts of a message. Attachments and file contents
// are left out: the record says what was asked, not what was pasted in.
function promptText(parts) {
@@ -135,7 +153,7 @@ function promptText(parts) {
}
}
-export const CasbinGatewayMonitor = async () => {
+export const CasbinGatewayMonitor = async ({ directory } = {}) => {
return {
event: async ({ event }) => {
const mapping = EVENTS[event?.type];
@@ -180,7 +198,7 @@ export const CasbinGatewayMonitor = async () => {
});
// Throwing is how a plugin refuses a call: opencode abandons the tool
and
// hands the message back to the model.
- const refusal = await decide(input?.tool, input?.sessionID,
input?.callID);
+ const refusal = await decide(input?.tool, input?.sessionID,
input?.callID, output?.args, directory);
if (refusal) throw new Error(refusal);
},
diff --git a/controllers/agent.go b/controllers/agent.go
index 62cac6d..d1dcb53 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -402,10 +402,12 @@ func (c *ApiController) CheckAgentTool() {
}
var form struct {
- Agent string `json:"agent"`
- Tool string `json:"tool"`
- SessionKey string `json:"sessionKey"`
- ToolUseId string `json:"toolUseId"`
+ Agent string `json:"agent"`
+ Tool string `json:"tool"`
+ SessionKey string `json:"sessionKey"`
+ ToolUseId string `json:"toolUseId"`
+ Input map[string]any `json:"input"`
+ Cwd string `json:"cwd"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
c.ResponseError(err.Error())
@@ -423,7 +425,7 @@ func (c *ApiController) CheckAgentTool() {
}
agentIds := agentmonitor.SharedAgentIds(form.Agent)
- allowed, reason, err := object.CheckAgentTool(agentIds, form.Tool)
+ allowed, reason, err := object.CheckAgentTool(agentIds, form.Tool,
form.Input, form.Cwd)
if err != nil {
beego.Error("agent tool check failed, the call was allowed:",
err)
}
diff --git a/controllers/permission.go b/controllers/permission.go
index 9f32633..c675346 100644
--- a/controllers/permission.go
+++ b/controllers/permission.go
@@ -26,18 +26,24 @@ import (
// 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"`
- Model string `json:"model"`
- Policy []string `json:"policy"`
+ Permission *object.AgentPermission `json:"permission"`
+ Groups []object.ToolGroup `json:"groups"`
+ Model string `json:"model"`
+ Policy []string `json:"policy"`
+ Packs []object.PermissionPack `json:"packs"`
+ GuardModel string `json:"guardModel"`
+ GuardPolicy []string `json:"guardPolicy"`
}
func newAgentPermissionInfo(permission *object.AgentPermission, owner string)
*agentPermissionInfo {
return &agentPermissionInfo{
- Permission: permission,
- Groups: object.ToolGroups(mcpItemsOf(permission.Name,
owner)),
- Model: object.PermissionModelText,
- Policy: permission.PolicyText(),
+ Permission: permission,
+ Groups: object.ToolGroups(mcpItemsOf(permission.Name,
owner)),
+ Model: object.PermissionModelText,
+ Policy: permission.PolicyText(),
+ Packs: object.PermissionPacks(),
+ GuardModel: object.GuardModelText,
+ GuardPolicy: permission.GuardText(),
}
}
@@ -134,3 +140,38 @@ func (c *ApiController) UpdateAgentPermission() {
c.ResponseOk(newAgentPermissionInfo(form.Permission, form.Owner))
}
+
+func (c *ApiController) GetPermissionPacks() {
+ if c.RequireAdmin() {
+ return
+ }
+ c.ResponseOk(object.PermissionPacks())
+}
+
+func (c *ApiController) SetPermissionPack() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ Pack string `json:"pack"`
+ On bool `json:"on"`
+ Agents []string `json:"agents"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ for _, agentId := range form.Agents {
+ if !agent.IsKnownAgentId(agentId) {
+ c.ResponseError("unknown agent: " + agentId)
+ return
+ }
+ }
+
+ if err := object.SetPermissionPack(form.Agents, form.Pack, form.On);
err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk()
+}
diff --git a/object/permission.go b/object/permission.go
index 5a5042d..e04a1ed 100644
--- a/object/permission.go
+++ b/object/permission.go
@@ -62,6 +62,10 @@ type AgentPermission struct {
// Rules are extra casbin policy lines, written by hand on the advanced
view
// and appended to the compiled ones. Each is "sub, obj, act, eft".
Rules []string `xorm:"mediumtext" json:"rules"`
+
+ // Guards are "kind, pattern, eft", see permission_guard.go.
+ Packs []string `xorm:"mediumtext" json:"packs"`
+ Guards []string `xorm:"mediumtext" json:"guards"`
}
func (permission *AgentPermission) GetId() string {
@@ -81,6 +85,8 @@ func DefaultAgentPermission(agentId string) *AgentPermission {
Providers: []string{},
Tools: map[string]bool{},
Rules: []string{},
+ Packs: []string{},
+ Guards: []string{},
}
}
@@ -152,7 +158,7 @@ func UpdateAgentPermission(agentId string, permission
*AgentPermission) error {
// Cols() is what writes an emptied list or a switch turned
off: xorm
// skips zero values otherwise.
_, err = ormer.Engine.ID(core.PK{AgentOwner, agentId}).
- Cols("enabled", "model_mode", "models",
"provider_mode", "providers", "tools", "rules", "updated_time").
+ Cols("enabled", "model_mode", "models",
"provider_mode", "providers", "tools", "rules", "packs", "guards",
"updated_time").
Update(permission)
if err != nil {
return err
@@ -160,6 +166,7 @@ func UpdateAgentPermission(agentId string, permission
*AgentPermission) error {
}
dropAgentEnforcer(agentId)
+ dropCallGuard(agentId)
return nil
}
@@ -186,6 +193,19 @@ func validateAgentPermission(permission *AgentPermission)
error {
return err
}
}
+ for _, guard := range permission.Guards {
+ if strings.TrimSpace(guard) == "" {
+ continue
+ }
+ if _, err := parseGuardRule(guard); err != nil {
+ return err
+ }
+ }
+ for _, pack := range permission.Packs {
+ if !containsString(PermissionPackNames(), pack) {
+ return fmt.Errorf("unknown permission pack: %s", pack)
+ }
+ }
return nil
}
@@ -210,10 +230,18 @@ func normalizeAgentPermission(permission
*AgentPermission) {
if permission.Rules == nil {
permission.Rules = []string{}
}
+ if permission.Packs == nil {
+ permission.Packs = []string{}
+ }
+ if permission.Guards == nil {
+ permission.Guards = []string{}
+ }
permission.Models = trimList(permission.Models)
permission.Providers = trimList(permission.Providers)
permission.Rules = trimList(permission.Rules)
+ permission.Packs = trimList(permission.Packs)
+ permission.Guards = trimList(permission.Guards)
}
func trimList(values []string) []string {
diff --git a/object/permission_check.go b/object/permission_check.go
index 00faef7..e720e28 100644
--- a/object/permission_check.go
+++ b/object/permission_check.go
@@ -28,11 +28,12 @@ import "fmt"
// It answers "allowed" for an agent nobody has restricted, and for a tool that
// falls under no switch, so a hook installed everywhere costs nothing until
// someone turns permissions on.
-func CheckAgentTool(agentIds []string, tool string) (bool, string, error) {
+func CheckAgentTool(agentIds []string, tool string, input map[string]any, cwd
string) (bool, string, error) {
if tool == "" {
return true, "", nil
}
+ var facts *ToolCallFacts
for _, agentId := range agentIds {
guard, err := LoadAgentGuard(agentId)
if err != nil {
@@ -40,11 +41,64 @@ func CheckAgentTool(agentIds []string, tool string) (bool,
string, error) {
// caller logs this and the tool call goes ahead.
return true, "", err
}
- if guard == nil || guard.AllowTool(tool) {
+ if guard == nil {
continue
}
- return false, fmt.Sprintf("the permissions of agent %s do not
allow %s (%s)",
- agentId, tool, ToolItemOf(tool)), nil
+ if !guard.AllowTool(tool) {
+ return false, fmt.Sprintf("the permissions of agent %s
do not allow %s (%s)",
+ agentId, tool, ToolItemOf(tool)), nil
+ }
+
+ permission, err := GetAgentPermission(agentId)
+ if err != nil {
+ return true, "", err
+ }
+ callGuard, err := loadCallGuard(permission)
+ if err != nil {
+ return true, "", err
+ }
+ if callGuard == nil {
+ continue
+ }
+ if facts == nil {
+ read := ReadToolCallFacts(input, cwd)
+ facts = &read
+ }
+ if facts.empty() {
+ continue
+ }
+ if reason := callGuard.check(agentId, *facts); reason != "" {
+ return false, fmt.Sprintf("the permissions of agent %s
refuse this %s call: %s", agentId, tool, reason), nil
+ }
}
return true, "", nil
}
+
+// SetPermissionPack also enables permissions when turning a pack on.
+func SetPermissionPack(agentIds []string, pack string, on bool) error {
+ if !containsString(PermissionPackNames(), pack) {
+ return fmt.Errorf("unknown permission pack: %s", pack)
+ }
+
+ for _, agentId := range agentIds {
+ permission, err := GetAgentPermission(agentId)
+ if err != nil {
+ return err
+ }
+ packs := []string{}
+ for _, name := range permission.Packs {
+ if name != pack {
+ packs = append(packs, name)
+ }
+ }
+ if on {
+ packs = append(packs, pack)
+ permission.Enabled = true
+ }
+ permission.Packs = packs
+ if err := UpdateAgentPermission(agentId, permission); err !=
nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/object/permission_guard.go b/object/permission_guard.go
new file mode 100644
index 0000000..a268af9
--- /dev/null
+++ b/object/permission_guard.go
@@ -0,0 +1,446 @@
+// 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.
+
+// Guards judge a tool call's arguments at the hook: its commands, paths and
hosts.
+
+package object
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+
+ "github.com/casbin/casbin/v2"
+ "github.com/casbin/casbin/v2/model"
+)
+
+const (
+ GuardCommand = "command"
+ GuardPath = "path"
+ GuardHost = "host"
+)
+
+var guardKinds = []string{GuardCommand, GuardPath, GuardHost}
+
+// GuardModelText: the first matching rule decides; each kind ends in an
allow-all.
+const GuardModelText = `[request_definition]
+r = sub, kind, value
+
+[policy_definition]
+p = sub, kind, pattern, eft
+
+[policy_effect]
+e = priority(p.eft) || deny
+
+[matchers]
+m = keyMatch(r.sub, p.sub) && r.kind == p.kind && guardMatch(r.kind, r.value,
p.pattern)
+`
+
+// parseGuardRule reads "kind, pattern, eft"; the pattern may contain commas.
+func parseGuardRule(rule string) ([]string, error) {
+ rule = strings.TrimSpace(rule)
+ first := strings.Index(rule, ",")
+ last := strings.LastIndex(rule, ",")
+ if first < 0 || first == last {
+ return nil, fmt.Errorf("a guard takes \"kind, pattern, eft\":
%s", rule)
+ }
+
+ kind := strings.TrimSpace(rule[:first])
+ pattern := strings.TrimSpace(rule[first+1 : last])
+ effect := strings.TrimSpace(rule[last+1:])
+ if !containsString(guardKinds, kind) {
+ return nil, fmt.Errorf("the kind of a guard is command, path or
host: %s", rule)
+ }
+ if pattern == "" {
+ return nil, fmt.Errorf("a guard has an empty pattern: %s", rule)
+ }
+ if effect != effectAllow && effect != effectDeny {
+ return nil, fmt.Errorf("the effect of a guard is \"allow\" or
\"deny\": %s", rule)
+ }
+ if kind == GuardCommand {
+ if _, err := regexp.Compile(pattern); err != nil {
+ return nil, fmt.Errorf("a command guard is a regular
expression: %s", err.Error())
+ }
+ }
+ return []string{kind, pattern, effect}, nil
+}
+
+type guardSource struct {
+ rule []string
+ from string
+}
+
+// guardLines: hand-written guards, then packs, then an allow-all per kind.
+func (permission *AgentPermission) guardLines() []guardSource {
+ lines := []guardSource{}
+ seen := map[string]bool{}
+ add := func(parsed []string, from string) {
+ rule := []string{permission.Name, parsed[0], parsed[1],
parsed[2]}
+ key := strings.Join(rule, "\x00")
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ lines = append(lines, guardSource{rule: rule, from: from})
+ }
+
+ for _, guard := range permission.Guards {
+ if parsed, err := parseGuardRule(guard); err == nil {
+ add(parsed, "")
+ }
+ }
+ for _, pack := range PermissionPacks() {
+ if !containsString(permission.Packs, pack.Name) {
+ continue
+ }
+ for _, guard := range pack.Guards {
+ if parsed, err := parseGuardRule(guard); err == nil {
+ add(parsed, pack.Name)
+ }
+ }
+ }
+ for _, kind := range guardKinds {
+ add([]string{kind, "*", effectAllow}, "")
+ }
+ return lines
+}
+
+func (permission *AgentPermission) GuardText() []string {
+ lines := []string{}
+ for _, line := range permission.guardLines() {
+ lines = append(lines, "p, "+strings.Join(line.rule, ", "))
+ }
+ return lines
+}
+
+type callGuard struct {
+ enforcer *casbin.Enforcer
+ sources map[string]string
+}
+
+var (
+ callGuardLock sync.Mutex
+ callGuardCache = map[string]*callGuard{}
+)
+
+func loadCallGuard(permission *AgentPermission) (*callGuard, error) {
+ if !permission.Enabled || (len(permission.Guards) == 0 &&
len(permission.Packs) == 0) {
+ return nil, nil
+ }
+
+ callGuardLock.Lock()
+ defer callGuardLock.Unlock()
+ if guard := callGuardCache[permission.Name]; guard != nil {
+ return guard, nil
+ }
+
+ guardModel, err := model.NewModelFromString(GuardModelText)
+ if err != nil {
+ return nil, err
+ }
+ enforcer, err := casbin.NewEnforcer(guardModel)
+ if err != nil {
+ return nil, err
+ }
+ enforcer.AddFunction("guardMatch", func(args ...interface{})
(interface{}, error) {
+ kind, _ := args[0].(string)
+ value, _ := args[1].(string)
+ pattern, _ := args[2].(string)
+ return guardMatch(kind, value, pattern), nil
+ })
+
+ lines := permission.guardLines()
+ rules := [][]string{}
+ sources := map[string]string{}
+ for _, line := range lines {
+ rules = append(rules, line.rule)
+ sources[strings.Join(line.rule, "\x00")] = line.from
+ }
+ if _, err = enforcer.AddPolicies(rules); err != nil {
+ return nil, err
+ }
+
+ guard := &callGuard{enforcer: enforcer, sources: sources}
+ callGuardCache[permission.Name] = guard
+ return guard, nil
+}
+
+func dropCallGuard(agentId string) {
+ callGuardLock.Lock()
+ defer callGuardLock.Unlock()
+ delete(callGuardCache, agentId)
+}
+
+// check returns why a call is refused, or "" if it may run.
+func (guard *callGuard) check(agentId string, facts ToolCallFacts) string {
+ values := [][2]string{}
+ for _, command := range facts.Commands {
+ values = append(values, [2]string{GuardCommand, command})
+ }
+ for _, path := range facts.Paths {
+ values = append(values, [2]string{GuardPath, path})
+ }
+ for _, host := range facts.Hosts {
+ values = append(values, [2]string{GuardHost, host})
+ }
+
+ for _, value := range values {
+ allowed, explain, err := guard.enforcer.EnforceEx(agentId,
value[0], value[1])
+ if err != nil || allowed {
+ continue
+ }
+ reason := fmt.Sprintf("the %s %q is not allowed", value[0],
shorten(value[1], 200))
+ if len(explain) == 4 {
+ if from := guard.sources[strings.Join(explain,
"\x00")]; from != "" {
+ reason += fmt.Sprintf(" by the %q pack", from)
+ } else {
+ reason += fmt.Sprintf(" by the guard %q",
explain[2])
+ }
+ }
+ return reason
+ }
+ return ""
+}
+
+func shorten(value string, limit int) string {
+ runes := []rune(value)
+ if len(runes) <= limit {
+ return value
+ }
+ return string(runes[:limit]) + "…"
+}
+
+func guardMatch(kind string, value string, pattern string) bool {
+ if pattern == "*" {
+ return true
+ }
+ switch kind {
+ case GuardCommand:
+ matcher, err := cachedRegexp(pattern)
+ return err == nil && matcher.MatchString(value)
+ case GuardPath:
+ matcher, err := cachedRegexp(globRegexp(expandHome(pattern)))
+ return err == nil &&
matcher.MatchString(normalizeGuardPath(value))
+ case GuardHost:
+ return hostMatch(value, pattern)
+ }
+ return false
+}
+
+var (
+ regexpLock sync.Mutex
+ regexpCache = map[string]*regexp.Regexp{}
+)
+
+func cachedRegexp(pattern string) (*regexp.Regexp, error) {
+ regexpLock.Lock()
+ defer regexpLock.Unlock()
+ if compiled := regexpCache[pattern]; compiled != nil {
+ return compiled, nil
+ }
+ compiled, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, err
+ }
+ regexpCache[pattern] = compiled
+ return compiled, nil
+}
+
+var caseInsensitivePaths = runtime.GOOS == "windows" || runtime.GOOS ==
"darwin"
+
+// globRegexp: "**/" spans directories; "*" and "?" stay in one segment.
+func globRegexp(glob string) string {
+ glob = normalizeGuardPath(glob)
+ var builder strings.Builder
+ if caseInsensitivePaths {
+ builder.WriteString("(?i)")
+ }
+ builder.WriteString("^")
+ for index := 0; index < len(glob); index++ {
+ switch char := glob[index]; char {
+ case '*':
+ if strings.HasPrefix(glob[index:], "**/") {
+ builder.WriteString("(?:.*/)?")
+ index += 2
+ } else if strings.HasPrefix(glob[index:], "**") {
+ builder.WriteString(".*")
+ index++
+ } else {
+ builder.WriteString("[^/]*")
+ }
+ case '?':
+ builder.WriteString("[^/]")
+ default:
+ builder.WriteString(regexp.QuoteMeta(string(char)))
+ }
+ }
+ builder.WriteString("$")
+ return builder.String()
+}
+
+func expandHome(path string) string {
+ if path != "~" && !strings.HasPrefix(path, "~/") &&
!strings.HasPrefix(path, `~\`) {
+ return path
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return path
+ }
+ return home + path[1:]
+}
+
+func normalizeGuardPath(path string) string {
+ path = expandHome(strings.TrimSpace(path))
+ if path == "" {
+ return ""
+ }
+ return filepath.ToSlash(filepath.Clean(path))
+}
+
+// hostMatch reads "*.example.com" as example.com and every host under it.
+func hostMatch(host string, pattern string) bool {
+ host = strings.ToLower(strings.TrimSuffix(host, "."))
+ pattern = strings.ToLower(strings.TrimSpace(pattern))
+ if suffix, ok := strings.CutPrefix(pattern, "*."); ok {
+ return host == suffix || strings.HasSuffix(host, "."+suffix)
+ }
+ return host == pattern
+}
+
+type ToolCallFacts struct {
+ Commands []string
+ Paths []string
+ Hosts []string
+}
+
+func (facts ToolCallFacts) empty() bool {
+ return len(facts.Commands) == 0 && len(facts.Paths) == 0 &&
len(facts.Hosts) == 0
+}
+
+// Argument names agents use for a command, a path and a URL.
+var (
+ commandKeys = map[string]bool{"command": true, "cmd": true,
"command_line": true, "commandline": true, "script": true}
+ pathKeys = map[string]bool{
+ "file_path": true, "filepath": true, "path": true,
"absolute_path": true, "notebook_path": true,
+ "target_file": true, "target_directory": true, "dir_path":
true, "directory": true,
+ "file": true, "filename": true, "source": true, "destination":
true, "old_path": true,
+ "new_path": true, "paths": true, "file_paths": true, "files":
true,
+ }
+ urlKeys = map[string]bool{"url": true, "uri": true, "urls": true,
"href": true}
+)
+
+var (
+ urlInText =
regexp.MustCompile(`(?i)\b(?:https?|ftp|wss?)://[^\s'"<>()\x60]+`)
+ downloadWords = map[string]bool{"curl": true, "wget": true, "iwr":
true, "irm": true,
+ "invoke-webrequest": true, "invoke-restmethod": true, "http":
true, "https": true}
+ bareHost =
regexp.MustCompile(`^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d+)?(?:/\S*)?$`)
+)
+
+// ReadToolCallFacts pulls commands, paths and hosts out of a call's arguments.
+func ReadToolCallFacts(input map[string]any, cwd string) ToolCallFacts {
+ facts := ToolCallFacts{}
+ seen := map[string]bool{}
+ add := func(list *[]string, kind string, value string) {
+ value = strings.TrimSpace(value)
+ if value == "" || seen[kind+value] {
+ return
+ }
+ seen[kind+value] = true
+ *list = append(*list, value)
+ }
+ addHost := func(raw string) {
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Hostname() == "" {
+ return
+ }
+ add(&facts.Hosts, GuardHost, parsed.Hostname())
+ }
+
+ var walk func(key string, value any, depth int)
+ walk = func(key string, value any, depth int) {
+ if depth > 4 {
+ return
+ }
+ switch typed := value.(type) {
+ case map[string]any:
+ for child, inner := range typed {
+ walk(strings.ToLower(child), inner, depth+1)
+ }
+ case []any:
+ for _, inner := range typed {
+ walk(key, inner, depth+1)
+ }
+ case string:
+ switch {
+ case commandKeys[key]:
+ add(&facts.Commands, GuardCommand, typed)
+ case pathKeys[key]:
+ add(&facts.Paths, GuardPath,
resolveGuardPath(typed, cwd))
+ case urlKeys[key]:
+ addHost(typed)
+ }
+ }
+ }
+ walk("", input, 0)
+
+ for _, command := range facts.Commands {
+ for _, raw := range urlInText.FindAllString(command, -1) {
+ addHost(raw)
+ }
+ for _, host := range downloadHosts(command) {
+ add(&facts.Hosts, GuardHost, host)
+ }
+ }
+ return facts
+}
+
+// downloadHosts finds scheme-less hosts after curl or wget.
+func downloadHosts(command string) []string {
+ hosts := []string{}
+ fields := strings.Fields(command)
+ for index, field := range fields {
+ if !downloadWords[strings.ToLower(filepath.Base(field))] {
+ continue
+ }
+ for _, argument := range fields[index+1:] {
+ argument = strings.Trim(argument, `'"`)
+ if strings.ContainsAny(argument, ";|&") {
+ break
+ }
+ if strings.HasPrefix(argument, "-") ||
strings.Contains(argument, "://") || !bareHost.MatchString(argument) {
+ continue
+ }
+ host := strings.SplitN(argument, "/", 2)[0]
+ if name, _, err := net.SplitHostPort(host); err == nil {
+ host = name
+ }
+ hosts = append(hosts, host)
+ }
+ }
+ return hosts
+}
+
+func resolveGuardPath(path string, cwd string) string {
+ path = strings.TrimSpace(path)
+ if path == "" || strings.HasPrefix(path, "~") ||
strings.HasPrefix(path, "/") || filepath.IsAbs(path) || cwd == "" {
+ return path
+ }
+ return filepath.Join(cwd, path)
+}
diff --git a/object/permission_packs.go b/object/permission_packs.go
new file mode 100644
index 0000000..fb21331
--- /dev/null
+++ b/object/permission_packs.go
@@ -0,0 +1,130 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+// PermissionPack is a named set of guards. Agents store the name, so pack
updates reach everyone.
+type PermissionPack struct {
+ Name string `json:"name"`
+ Guards []string `json:"guards"`
+}
+
+// segmentStart is where a shell command can begin, so "echo rm -rf" is not a
delete.
+const segmentStart =
`(?:^|[;&|(\x60\n]|\$\()\s*(?:sudo\s+|command\s+|exec\s+|xargs\s+)*`
+
+func PermissionPacks() []PermissionPack {
+ return []PermissionPack{
+ {
+ Name: "no-destructive-commands",
+ Guards: []string{
+ "command, " + segmentStart +
`rm\s(?:[^;&|\n]*\s)?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?:\s|$), deny`,
+ "command, (?i)" + segmentStart +
`(?:remove-item|rm|ri|del|erase|rd|rmdir)\s[^;&|\n]*-recurse\b, deny`,
+ "command, (?i)" + segmentStart +
`(?:rd|rmdir|del|erase)\s(?:[^;&|\n]*\s)?/s\b, deny`,
+ "command, " + segmentStart +
`find\s[^;&|\n]*\s-delete\b, deny`,
+ "command, " + segmentStart +
`git\s+reset\s(?:[^;&|\n]*\s)?--hard\b, deny`,
+ "command, " + segmentStart +
`git\s+clean\s(?:[^;&|\n]*\s)?-[a-zA-Z]*f, deny`,
+ "command, " + segmentStart +
`git\s+push\s(?:[^;&|\n]*\s)?(?:-f|--force|--force-with-lease|--mirror|--delete)(?:[=\s]|$),
deny`,
+ "command, " + segmentStart +
`git\s+(?:checkout|restore)\s(?:[^;&|\n]*\s)?(?:--\s+)?\.(?:\s|$), deny`,
+ "command, " + segmentStart +
`(?:mkfs(?:\.\w+)?|fdisk|parted|wipefs|shred)\s, deny`,
+ "command, " + segmentStart +
`dd\s[^;&|\n]*\bof=/dev/, deny`,
+ "command, " + `>\s*/dev/(?:sd|nvme|disk|hd)\w*,
deny`,
+ "command, " + segmentStart +
`chmod\s+(?:-[a-zA-Z]*R[a-zA-Z]*\s+)?(?:0?777|a\+rwx)\s+/(?:\s|$), deny`,
+ "command, (?i)" + segmentStart +
`(?:format|format-volume|clear-disk)\s, deny`,
+ "command, " + segmentStart +
`(?:shutdown|reboot|halt|poweroff)(?:\s|$), deny`,
+ "command, " +
`:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:, deny`,
+ },
+ },
+ {
+ Name: "protect-secrets",
+ Guards: []string{
+ // Env templates hold no secrets.
+ "path, **/.env.example, allow",
+ "path, **/.env.sample, allow",
+ "path, **/.env.template, allow",
+ "path, **/.env, deny",
+ "path, **/.env.*, deny",
+ "path, ~/.ssh/**, deny",
+ "path, ~/.aws/**, deny",
+ "path, ~/.azure/**, deny",
+ "path, ~/.config/gcloud/**, deny",
+ "path, ~/.gnupg/**, deny",
+ "path, ~/.kube/**, deny",
+ "path, ~/.docker/config.json, deny",
+ "path, ~/.netrc, deny",
+ "path, ~/.git-credentials, deny",
+ "path, ~/.npmrc, deny",
+ "path, ~/.pypirc, deny",
+ "path, ~/.config/gh/**, deny",
+ "path, ~/.claude/.credentials.json, deny",
+ "path, ~/.codex/auth.json, deny",
+ "path, ~/.gemini/oauth_creds.json, deny",
+ "path, **/*.pem, deny",
+ "path, **/*.key, deny",
+ // The same files reached through the shell.
+ `command,
(?i)(?:[/\\]\.ssh[/\\]|\bid_(?:rsa|ed25519|ecdsa|dsa)\b|[/\\]\.aws[/\\]credentials|[/\\]\.gnupg[/\\]|\.git-credentials|[/\\]\.netrc\b|[/\\]\.kube[/\\]config|[/\\]\.codex[/\\]auth\.json|[/\\]\.claude[/\\]\.credentials\.json|oauth_creds\.json),
deny`,
+ `command,
(?:^|[\s'"/\\=<])\.env(?:\.(?:local|dev|development|prod|production|staging|test))?(?:$|[\s'";|&>)]),
deny`,
+ `command, (?i)` + segmentStart +
`(?:printenv|env|set|get-childitem\s+env:|gci\s+env:|dir\s+env:)\s*(?:$|[;&|>]),
deny`,
+ },
+ },
+ {
+ Name: "egress-allowlist",
+ Guards: []string{
+ "host, localhost, allow",
+ "host, 127.0.0.1, allow",
+ "host, ::1, allow",
+ "host, *.github.com, allow",
+ "host, *.githubusercontent.com, allow",
+ "host, *.gitlab.com, allow",
+ "host, *.npmjs.org, allow",
+ "host, *.npmjs.com, allow",
+ "host, *.yarnpkg.com, allow",
+ "host, pypi.org, allow",
+ "host, *.pythonhosted.org, allow",
+ "host, proxy.golang.org, allow",
+ "host, sum.golang.org, allow",
+ "host, *.go.dev, allow",
+ "host, crates.io, allow",
+ "host, *.crates.io, allow",
+ "host, *.rust-lang.org, allow",
+ "host, *.maven.org, allow",
+ "host, *.docker.io, allow",
+ "host, *.npmmirror.com, allow",
+ "host, goproxy.cn, allow",
+ "host, *.tuna.tsinghua.edu.cn, allow",
+ "host, *, deny",
+ },
+ },
+ {
+ Name: "no-publishing",
+ Guards: []string{
+ "command, " + segmentStart +
`git\s+push(?:\s|$), deny`,
+ "command, " + segmentStart +
`(?:npm|pnpm|yarn)\s+publish\b, deny`,
+ "command, " + segmentStart +
`cargo\s+publish\b, deny`,
+ "command, " + segmentStart +
`(?:twine\s+upload|poetry\s+publish|uv\s+publish|flit\s+publish)\b, deny`,
+ "command, " + segmentStart +
`(?:docker|podman)\s+push\b, deny`,
+ "command, " + segmentStart +
`gh\s+(?:release\s+(?:create|upload|edit|delete)|pr\s+(?:merge|create)|repo\s+(?:create|delete|edit)|gist\s+create)\b,
deny`,
+ "command, " + segmentStart +
`(?:kubectl|helm)\s+(?:apply|delete|install|upgrade|uninstall|rollout)\b, deny`,
+ "command, " + segmentStart +
`terraform\s+(?:apply|destroy)\b, deny`,
+ },
+ },
+ }
+}
+
+func PermissionPackNames() []string {
+ names := []string{}
+ for _, pack := range PermissionPacks() {
+ names = append(names, pack.Name)
+ }
+ return names
+}
diff --git a/routers/router.go b/routers/router.go
index 652e0c8..9cf221a 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -78,6 +78,8 @@ func initAPI() {
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-permission-packs", &controllers.ApiController{},
"GET:GetPermissionPacks")
+ beego.Router("/api/set-permission-pack", &controllers.ApiController{},
"POST:SetPermissionPack")
beego.Router("/api/get-agent-processes", &controllers.ApiController{},
"GET:GetAgentProcesses")
beego.Router("/api/get-agent-egress", &controllers.ApiController{},
"GET:GetAgentEgress")
beego.Router("/api/start-agent", &controllers.ApiController{},
"POST:StartAgent")
diff --git a/web/src/backend/PermissionBackend.ts
b/web/src/backend/PermissionBackend.ts
index 9323a92..77d1aa3 100644
--- a/web/src/backend/PermissionBackend.ts
+++ b/web/src/backend/PermissionBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {AgentPermission, AgentPermissionInfo} from "@/types";
+import type {AgentPermission, AgentPermissionInfo, PermissionPack} from
"@/types";
/** 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
@@ -36,3 +36,12 @@ export function updateAgentPermission(agentId: string,
permission: AgentPermissi
permission: permission,
});
}
+
+export function getPermissionPacks() {
+ return request<PermissionPack[]>("/api/get-permission-packs");
+}
+
+/** Turning a pack on also enables those agents' permissions. */
+export function setPermissionPack(pack: string, on: boolean, agents: string[])
{
+ return request<void>("/api/set-permission-pack", "POST", {pack: pack, on:
on, agents: agents});
+}
diff --git a/web/src/components/PermissionCard.tsx
b/web/src/components/PermissionCard.tsx
index 53d6c6d..c191b65 100644
--- a/web/src/components/PermissionCard.tsx
+++ b/web/src/components/PermissionCard.tsx
@@ -31,6 +31,7 @@ import i18next from "i18next";
import * as PermissionBackend from "@/backend/PermissionBackend";
import * as Setting from "@/Setting";
+import {PackSwitches} from "@/components/PermissionPacks";
import {SimpleSelect} from "@/components/shared/simple-select";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -236,18 +237,15 @@ function AdvancedView({
info,
busy,
onRules,
+ onGuards,
}: {
info: AgentPermissionInfo;
busy: boolean;
onRules: (rules: string[]) => void;
+ onGuards: (guards: string[]) => void;
}) {
const policy = info.policy ?? [];
- const stored = (info.permission.rules ?? []).join("\n");
- const [draft, setDraft] = React.useState(stored);
-
- // The textarea is only a draft until it loses focus, so a half-typed rule is
- // never saved; a rule saved elsewhere replaces it.
- React.useEffect(() => setDraft(stored), [stored]);
+ const guardPolicy = info.guardPolicy ?? [];
return (
<div className="space-y-3 rounded-md border p-3">
@@ -260,7 +258,7 @@ function AdvancedView({
variant="outline"
size="xs"
onClick={() => {
- copy(`${info.model}\n${policy.join("\n")}\n`);
+
copy(`${info.model}\n${policy.join("\n")}\n\n${info.guardModel}\n${guardPolicy.join("\n")}\n`);
Setting.showMessage("success", i18next.t("agent:Casbin
configuration copied"));
}}
>
@@ -283,25 +281,77 @@ function AdvancedView({
</pre>
</div>
+ <RuleDraft
+ label={i18next.t("agent:Extra rules")}
+ hint={i18next.t("agent:Extra rules hint")}
+ placeholder="claude-code, tool:mcp/*, use, deny"
+ stored={info.permission.rules ?? []}
+ busy={busy}
+ onSave={onRules}
+ />
+
<div className="space-y-1">
- <div className="text-xs text-muted-foreground">{i18next.t("agent:Extra
rules")}</div>
- <Textarea
- className="font-mono text-xs"
- rows={3}
- spellCheck={false}
- value={draft}
- disabled={busy}
- placeholder="claude-code, tool:mcp/*, use, deny"
- onChange={event => setDraft(event.target.value)}
- onBlur={() => {
- const rules = draft.split("\n").map(line =>
line.trim()).filter(line => line !== "");
- if (rules.join("\n") !== stored) {
- onRules(rules);
- }
- }}
- />
- <p className="text-xs text-muted-foreground">{i18next.t("agent:Extra
rules hint")}</p>
+ <div className="text-xs text-muted-foreground">guard_model.conf</div>
+ <pre className="overflow-x-auto rounded-md bg-muted p-2
text-xs">{info.guardModel}</pre>
+ </div>
+
+ <div className="space-y-1">
+ <div className="text-xs text-muted-foreground">guard_policy.csv</div>
+ <pre className="max-h-72 overflow-auto rounded-md bg-muted p-2
text-xs">{guardPolicy.join("\n")}</pre>
</div>
+
+ <RuleDraft
+ label={i18next.t("agent:Extra guards")}
+ hint={i18next.t("agent:Extra guards hint")}
+ placeholder="host, *.example.com, allow"
+ stored={info.permission.guards ?? []}
+ busy={busy}
+ onSave={onGuards}
+ />
+ </div>
+ );
+}
+
+/** Saved on blur, so a half-typed line is never saved. */
+function RuleDraft({
+ label,
+ hint,
+ placeholder,
+ stored,
+ busy,
+ onSave,
+}: {
+ label: string;
+ hint: string;
+ placeholder: string;
+ stored: string[];
+ busy: boolean;
+ onSave: (lines: string[]) => void;
+}) {
+ const joined = stored.join("\n");
+ const [draft, setDraft] = React.useState(joined);
+
+ React.useEffect(() => setDraft(joined), [joined]);
+
+ return (
+ <div className="space-y-1">
+ <div className="text-xs text-muted-foreground">{label}</div>
+ <Textarea
+ className="font-mono text-xs"
+ rows={3}
+ spellCheck={false}
+ value={draft}
+ disabled={busy}
+ placeholder={placeholder}
+ onChange={event => setDraft(event.target.value)}
+ onBlur={() => {
+ const lines = draft.split("\n").map(line => line.trim()).filter(line
=> line !== "");
+ if (lines.join("\n") !== joined) {
+ onSave(lines);
+ }
+ }}
+ />
+ <p className="text-xs text-muted-foreground">{hint}</p>
</div>
);
}
@@ -488,6 +538,14 @@ export function PermissionCard({
<p className="text-xs
text-muted-foreground">{i18next.t("agent:Tool hint")}</p>
</div>
+ <PackSwitches
+ agent={agent}
+ packs={info.packs ?? []}
+ permission={permission}
+ busy={busy || !permission.enabled}
+ onPacks={packs => save({packs: packs})}
+ />
+
<div className="space-y-2">
<div className="text-sm
font-medium">{i18next.t("provider:Models")}</div>
<SimpleSelect
@@ -553,7 +611,12 @@ export function PermissionCard({
</div>
{advanced ? (
- <AdvancedView info={info} busy={busy} onRules={rules => save({rules:
rules})} />
+ <AdvancedView
+ info={info}
+ busy={busy}
+ onRules={rules => save({rules: rules})}
+ onGuards={guards => save({guards: guards})}
+ />
) : null}
</CardContent>
</Card>
diff --git a/web/src/components/PermissionPacks.tsx
b/web/src/components/PermissionPacks.tsx
new file mode 100644
index 0000000..64105e6
--- /dev/null
+++ b/web/src/components/PermissionPacks.tsx
@@ -0,0 +1,217 @@
+// 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 {ChevronDown, ChevronRight, Globe, KeyRound, Rocket, ShieldCheck,
Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import * as PermissionBackend from "@/backend/PermissionBackend";
+import * as Setting from "@/Setting";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Switch} from "@/components/ui/switch";
+import {agentDecidesHere} from "@/lib/agents";
+import type {Agent, AgentPermission, PermissionPack} from "@/types";
+
+const packTexts: {[pack: string]: {title: string; hint: string; icon:
React.ElementType}} = {
+ "no-destructive-commands": {
+ title: "agent:No destructive commands",
+ hint: "agent:No destructive commands hint",
+ icon: Trash2,
+ },
+ "protect-secrets": {title: "agent:Protect secrets", hint: "agent:Protect
secrets hint", icon: KeyRound},
+ "egress-allowlist": {title: "agent:Only known hosts", hint: "agent:Only
known hosts hint", icon: Globe},
+ "no-publishing": {title: "agent:No publishing", hint: "agent:No publishing
hint", icon: Rocket},
+};
+
+export function packTitle(pack: string) {
+ const known = packTexts[pack];
+ return known ? i18next.t(known.title) : pack;
+}
+
+function packHint(pack: string) {
+ const known = packTexts[pack];
+ return known ? i18next.t(known.hint) : "";
+}
+
+function packIcon(pack: string) {
+ return packTexts[pack]?.icon ?? ShieldCheck;
+}
+
+function PackGuards({pack}: {pack: PermissionPack}) {
+ const [open, setOpen] = React.useState(false);
+ const Chevron = open ? ChevronDown : ChevronRight;
+
+ return (
+ <div>
+ <button
+ type="button"
+ onClick={() => setOpen(!open)}
+ className="flex items-center gap-1 text-xs text-muted-foreground
hover:text-foreground"
+ >
+ <Chevron className="h-3.5 w-3.5" />
+ {i18next.t("agent:{count}
guards").split("{count}").join(String(pack.guards.length))}
+ </button>
+ {open ? (
+ <pre className="mt-1.5 max-h-56 overflow-auto rounded-md bg-muted p-2
text-xs">
+ {pack.guards.join("\n")}
+ </pre>
+ ) : null}
+ </div>
+ );
+}
+
+/** The pack cards on the Permissions page, each with an apply-to-all button.
*/
+export function PermissionPackBoard({
+ agents,
+ permissions,
+ onChanged,
+}: {
+ agents: Agent[];
+ permissions: AgentPermission[];
+ onChanged: () => void;
+}) {
+ const [packs, setPacks] = React.useState<PermissionPack[]>([]);
+ const [busy, setBusy] = React.useState("");
+
+ React.useEffect(() => {
+ PermissionBackend.getPermissionPacks()
+ .then(res => setPacks(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setPacks([]));
+ }, []);
+
+ if (packs.length === 0 || agents.length === 0) {
+ return null;
+ }
+
+ const agentIds = agents.map(agent => agent.agentId);
+ const unheld = agents.filter(agent => !agentDecidesHere(agent));
+ const hasPack = (agentId: string, pack: string) =>
+ permissions.some(permission => permission.name === agentId &&
permission.enabled && (permission.packs ?? []).includes(pack));
+
+ const setPack = (pack: string, on: boolean) => {
+ setBusy(pack);
+ PermissionBackend.setPermissionPack(pack, on, agentIds)
+ .then(res => {
+ if (res.status === "ok") {
+ Setting.showMessage("success", i18next.t(on ? "agent:Pack applied to
all" : "agent:Pack removed from all"));
+ onChanged();
+ } else {
+ Setting.showMessage("error", res.msg || i18next.t("agent:Failed to
save the permissions"));
+ }
+ })
+ .catch(err => Setting.showMessage("error", err.message || String(err)))
+ .then(() => setBusy(""));
+ };
+
+ return (
+ <div className="space-y-2">
+ <div>
+ <div className="flex items-center gap-1.5 text-sm font-medium">
+ <ShieldCheck className="h-4 w-4 text-muted-foreground" />
+ {i18next.t("agent:Policy packs")}
+ </div>
+ <p className="text-sm text-muted-foreground">{i18next.t("agent:Policy
packs hint")}</p>
+ </div>
+
+ <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
+ {packs.map(pack => {
+ const Icon = packIcon(pack.name);
+ const on = agentIds.filter(agentId => hasPack(agentId,
pack.name)).length;
+ const all = on === agentIds.length;
+ return (
+ <div key={pack.name} className="flex flex-col gap-2 rounded-md
border p-3">
+ <div className="flex items-start gap-2">
+ <Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
+ <div className="min-w-0 flex-1">
+ <div className="flex flex-wrap items-center gap-2 text-sm
font-medium">
+ {packTitle(pack.name)}
+ <Badge variant={on === 0 ? "muted" : "success"}
className="font-normal">
+ {`${on}/${agentIds.length}`}
+ </Badge>
+ </div>
+ <p className="text-sm
text-muted-foreground">{packHint(pack.name)}</p>
+ </div>
+ </div>
+ <div className="mt-auto flex items-center justify-between gap-2">
+ <PackGuards pack={pack} />
+ <Button
+ variant={all ? "outline" : "default"}
+ size="xs"
+ disabled={busy !== ""}
+ onClick={() => setPack(pack.name, !all)}
+ >
+ {i18next.t(all ? "agent:Remove from all agents" :
"agent:Apply to all agents")}
+ </Button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {unheld.length > 0 ? (
+ <p className="text-xs text-muted-foreground">
+ {i18next.t("agent:Packs not held hint")} {unheld.map(agent =>
agent.name).join(", ")}
+ </p>
+ ) : null}
+ </div>
+ );
+}
+
+export function PackSwitches({
+ agent,
+ packs,
+ permission,
+ busy,
+ onPacks,
+}: {
+ agent: Agent;
+ packs: PermissionPack[];
+ permission: AgentPermission;
+ busy: boolean;
+ onPacks: (packs: string[]) => void;
+}) {
+ const current = permission.packs ?? [];
+
+ return (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">{i18next.t("agent:Policy
packs")}</div>
+ {agentDecidesHere(agent) ? null : (
+ <p className="text-sm text-warning">{i18next.t("agent:Packs not held
on this agent")}</p>
+ )}
+ <div className="rounded-md border">
+ {packs.map(pack => {
+ const Icon = packIcon(pack.name);
+ return (
+ <label key={pack.name} className="flex items-start gap-3 border-b
p-2.5 text-sm last:border-b-0">
+ <Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
/>
+ <span className="min-w-0 flex-1">
+ {packTitle(pack.name)}
+ <span className="block text-xs
text-muted-foreground">{packHint(pack.name)}</span>
+ </span>
+ <Switch
+ className="mt-0.5 shrink-0"
+ checked={current.includes(pack.name)}
+ disabled={busy}
+ onCheckedChange={checked =>
+ onPacks(checked ? [...current, pack.name] :
current.filter(name => name !== pack.name))}
+ />
+ </label>
+ );
+ })}
+ </div>
+ <p className="text-xs text-muted-foreground">{i18next.t("agent:Packs
card hint")}</p>
+ </div>
+ );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d842470..c913680 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -65,6 +65,7 @@
"Any provider": "Any provider",
"Any source": "Any source",
"Apply a patch": "Apply a patch",
+ "Apply to all agents": "Apply to all agents",
"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.",
@@ -123,6 +124,8 @@
"Env override": "Environment override",
"Environment overrides this configuration": "The environment overrides
this configuration",
"Event": "Event",
+ "Extra guards": "Extra guards",
+ "Extra guards hint": "One guard per row, as \"kind, pattern, eft\": a
command is matched by a regular expression, a path by a glob (~ and ** work),
and a host by a domain, where *.example.com covers example.com too. They are
checked before every pack.",
"Extra rules": "Extra rules",
"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": "Failed",
@@ -215,11 +218,15 @@
"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",
"No connections yet": "No connections seen yet",
+ "No destructive commands": "No destructive commands",
+ "No destructive commands hint": "Recursive deletes (rm -rf, Remove-Item
-Recurse, rd /s, find -delete), git reset --hard, git clean -f, force pushes,
discarding every change at once, formatting a disk, and shutting the machine
down.",
"No instances yet": "No instances yet - add one to sign in with a second
account",
"No other provider speaks this API": "No other provider speaks this API",
"No output yet": "No output yet",
"No program hint": "Gateway found this agent's files but not its program,
so there is nothing here to start. Pick the program and it can be started from
here.",
"No provider yet": "No provider has been added yet.",
+ "No publishing": "No publishing",
+ "No publishing hint": "Anything that leaves the machine for good: git
push, publishing a package or an image, GitHub releases, opening or merging
pull requests, and kubectl, helm or terraform changes.",
"No rule yet": "no rule yet",
"No supported agents found": "No supported agents found",
"No suspicious uploads": "No suspicious uploads recorded",
@@ -242,11 +249,18 @@
"Onboarding connect agent detail": "Pick that provider on one of the agent
cards above.",
"Onboarding see it work": "See it work",
"Onboarding see it work detail": "Watch requests land in LLM Records once
traffic flows through.",
+ "Only known hosts": "Only known hosts",
+ "Only known hosts hint": "A URL a tool fetches, and a URL or curl/wget
target in a command, has to be on the list: code hosting, package registries
and their common mirrors, and this machine. Add your own on an agent's advanced
view as \"host, *.example.com, allow\".",
"Only the ones I pick": "Only the ones I pick",
"Open the sign-in page": "Open the sign-in page",
"Other": "Other",
"Outbound traffic": "Outbound traffic",
"Outbound traffic hint": "Gateway watches where this agent's own processes
send data. The proxy only sees what goes to the model; a repository uploaded
straight to cloud storage shows up here.",
+ "Pack applied to all": "The pack is on for every agent",
+ "Pack removed from all": "The pack is off for every agent",
+ "Packs card hint": "A pack refuses a tool call by what it does, which only
the agent's own hook can read before the call runs. Turning a pack on for an
agent here leaves its switches as they are.",
+ "Packs not held hint": "These agents have no hook that runs before a tool
call, so no pack can hold them:",
+ "Packs not held on this agent": "This agent has no hook that runs before a
tool call, so the packs below hold nothing on it.",
"Patch": "Turn on monitoring",
"Patch Status": "Monitoring",
"Patched": "Monitoring",
@@ -259,11 +273,15 @@
"Pi config hint": "pi keeps the endpoint in ~/.pi/agent/models.json: a
providers entry with baseUrl, api set to openai-completions and its own model
list, started on through defaultProvider and defaultModel in settings.json
beside it. PI_CODING_AGENT_DIR moves both files elsewhere.",
"Pick a version": "Pick a version",
"Planning and delegation": "Planning and delegation",
+ "Policy packs": "Policy packs",
+ "Policy packs hint": "Ready-made guards that read what a tool call does -
the command it runs, the files it touches, the hosts it reaches - and refuse it
before it runs. Casbin decides every call, and a release that tightens a pack
tightens it for every agent that has it on.",
"Preview": "Preview",
"Preview hint": "What Gateway would write, with the keys masked.
Everything else in these files is left as it is.",
"Processes": "Processes",
"Program path placeholder": "Or paste the full path to the program",
"Prompt ID": "Prompt ID",
+ "Protect secrets": "Protect secrets",
+ "Protect secrets hint": "SSH and GPG keys, cloud and cluster credentials,
package registry tokens, the agents' own sign-ins, .env files, *.pem and *.key
- read with a tool or through the shell - and dumping the environment.
.env.example and its kin stay readable.",
"Provider": "Provider",
"Provider enabled": "Provider enabled",
"Provider saved": "Provider saved",
@@ -282,6 +300,7 @@
"Remember something": "Remember something",
"Remove account": "Remove account",
"Remove account hint": "Gateway forgets its copy of this sign-in. The
agent keeps whatever it is signed in to right now.",
+ "Remove from all agents": "Remove from all agents",
"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.",
"Remove the line to clear it": "Remove that line, then start the agent
again.",
@@ -412,6 +431,7 @@
"{count} agents not in use": "{count} agents not in use",
"{count} agents not installed": "{count} agents not installed",
"{count} from transcripts": "{count} from transcripts",
+ "{count} guards": "{count} guards",
"{count} messages": "{count} messages",
"{count} resets left": "{count} rate limit resets left",
"{count} running": "{count} running",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 571fe0b..3d3b418 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -65,6 +65,7 @@
"Any provider": "任意供应商",
"Any source": "全部来源",
"Apply a patch": "应用补丁",
+ "Apply to all agents": "应用到所有Agent",
"Ask the user a question": "向用户提问",
"Auto refresh": "自动刷新",
"Base URL hint": "把下面的设置配给该Agent。到达这个地址的请求一律转发到上面选定的供应商,与请求里的模型名无关。",
@@ -123,6 +124,8 @@
"Env override": "环境变量覆盖",
"Environment overrides this configuration": "环境变量覆盖了该配置",
"Event": "事件",
+ "Extra guards": "额外守卫",
+ "Extra guards hint": "每行一条,格式为\"kind, pattern,
eft\":command用正则表达式匹配,path用通配符匹配(支持~和**),host按域名匹配,*.example.com也包括example.com本身。它们排在所有策略包之前。",
"Extra rules": "额外规则",
"Extra rules hint": "每行一条策略,格式为\"sub, obj, act,
eft\"。它们排在开关生成的规则之前,用来表达开关表达不了的例外。",
"Failed": "失败",
@@ -215,11 +218,15 @@
"No agent sessions yet": "还没有会话",
"No agent sessions yet - patch an agent to start collecting them":
"还没有会话,开启某个Agent客户端的监控后才会开始收集",
"No connections yet": "还没有看到连接",
+ "No destructive commands": "禁止破坏性命令",
+ "No destructive commands hint": "递归删除(rm -rf、Remove-Item -Recurse、rd
/s、find -delete)、git reset --hard、git clean -f、强制推送、一次丢弃所有改动、格式化磁盘、关机重启。",
"No instances yet": "暂无实例-添加一个即可用第二个账号登录",
"No other provider speaks this API": "没有其他供应商使用相同的API协议",
"No output yet": "暂无输出",
"No program hint": "网关找到了该Agent客户端的数据目录,但没有找到它的可执行程序,因此无法从这里启动。选择程序后即可启动。",
"No provider yet": "还没有添加任何供应商。",
+ "No publishing": "禁止发布和部署",
+ "No publishing hint": "一旦做了就收不回的外发操作:git
push、发布包或镜像、GitHub发布、创建或合并PR,以及kubectl、helm、terraform的变更。",
"No rule yet": "还没有任何规则",
"No supported agents found": "没有发现本机安装的Agent客户端",
"No suspicious uploads": "没有记录到可疑外发",
@@ -242,11 +249,18 @@
"Onboarding connect agent detail": "在上方的Agent卡片中选择该Provider。",
"Onboarding see it work": "查看效果",
"Onboarding see it work detail": "流量接入后,可在LLM Records中看到请求记录。",
+ "Only known hosts": "只允许访问白名单域名",
+ "Only known hosts hint":
"工具抓取的网址,以及命令里出现的网址或curl/wget目标,都必须在白名单里:代码托管平台、各包仓库及其常用镜像,以及本机。要加自己的域名,在Agent的高级视图里写一条\"host,
*.example.com, allow\"。",
"Only the ones I pick": "只允许勾选的",
"Open the sign-in page": "打开登录页面",
"Other": "其他",
"Outbound traffic": "外发监测",
"Outbound traffic hint":
"Gateway监测这个Agent自己的进程往哪里发数据。代理只能看到发给模型的请求,直接传到云存储的代码仓库会记在这里。",
+ "Pack applied to all": "已对所有Agent启用",
+ "Pack removed from all": "已从所有Agent移除",
+ "Packs card hint":
"策略包按工具调用实际要做的事来拒绝,而只有Agent自己的钩子能在调用执行前看到这些。在这里给某个Agent开启策略包,不会改动它的开关。",
+ "Packs not held hint": "以下Agent客户端没有在工具调用前触发的钩子,策略包管不到它们:",
+ "Packs not held on this agent": "这个Agent客户端没有在工具调用前触发的钩子,下面的策略包对它不生效。",
"Patch": "开启监控",
"Patch Status": "监控状态",
"Patched": "监控中",
@@ -259,11 +273,15 @@
"Pi config hint":
"pi的端点写在~/.pi/agent/models.json里:providers下的一个条目,含baseUrl、api(openai-completions)和它自己的模型列表;启动时用哪个则由旁边settings.json中的defaultProvider和defaultModel决定。PI_CODING_AGENT_DIR会把这两个文件挪到别处。",
"Pick a version": "选择版本",
"Planning and delegation": "规划与委派",
+ "Policy packs": "策略包",
+ "Policy packs hint":
"现成的一组守卫规则,看的是工具调用具体要做什么:跑什么命令、碰哪些文件、连哪些域名,并在执行前拒绝。每次调用都由Casbin判定;以后的版本收紧某个策略包,所有开着它的Agent都会跟着收紧。",
"Preview": "预览",
"Preview hint": "Gateway将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
"Processes": "进程",
"Program path placeholder": "或直接粘贴程序的完整路径",
"Prompt ID": "Prompt ID",
+ "Protect secrets": "保护密钥文件",
+ "Protect secrets hint":
"SSH和GPG密钥、云服务和集群凭据、包仓库Token、各Agent自己的登录凭据、.env文件、*.pem和*.key,无论用工具读还是在终端里读都会被拒绝,打印全部环境变量也一样。.env.example这类模板仍然可读。",
"Provider": "供应商",
"Provider enabled": "已启用供应商",
"Provider saved": "供应商已保存",
@@ -282,6 +300,7 @@
"Remember something": "记住信息",
"Remove account": "移除账号",
"Remove account hint": "Gateway将删除这份登录信息的副本。Agent当前登录的账号保持不变。",
+ "Remove from all agents": "从所有Agent移除",
"Remove instance": "移除实例",
"Remove instance hint": "Gateway将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
"Remove the line to clear it": "请删除该行,然后重新启动该Agent。",
@@ -412,6 +431,7 @@
"{count} agents not in use": "{count}个Agent未在使用",
"{count} agents not installed": "{count}个Agent未安装",
"{count} from transcripts": "{count}个来自会话记录",
+ "{count} guards": "{count}条规则",
"{count} messages": "{count}条消息",
"{count} resets left": "剩余{count}次重置额度",
"{count} running": "{count}个在运行",
diff --git a/web/src/pages/PermissionsPage.tsx
b/web/src/pages/PermissionsPage.tsx
index 24589ae..0509a0e 100644
--- a/web/src/pages/PermissionsPage.tsx
+++ b/web/src/pages/PermissionsPage.tsx
@@ -22,6 +22,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {PermissionCard} from "@/components/PermissionCard";
+import {PermissionPackBoard} from "@/components/PermissionPacks";
import {EmptyState} from "@/components/shared/empty-state";
import {AiDots} from "@/components/shared/loading";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
@@ -94,6 +95,7 @@ export default function PermissionsPage({account}: {account:
Account}) {
const {agents, scanned, scan} = useAgents(isAdmin);
const [permissions, setPermissions] = React.useState<AgentPermission[]>([]);
const [providers, setProviders] = React.useState<Provider[]>([]);
+ const [packsChanged, setPacksChanged] = React.useState(0);
// 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.
@@ -165,28 +167,38 @@ export default function PermissionsPage({account}:
{account: Account}) {
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 className="space-y-4">
+ <PermissionPackBoard
+ agents={unique}
+ permissions={permissions}
+ onChanged={() => {
+ load();
+ setPacksChanged(packsChanged + 1);
+ }}
+ />
+ <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
+ agent={selected}
+ providers={providers}
+ className="self-start"
+ key={`${selected.agentId}-${packsChanged}`}
+ onSaved={load}
/>
- ))}
+ ) : null}
</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 63abf44..3c0d0e3 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -391,6 +391,14 @@ export interface AgentPermission {
tools: {[group: string]: boolean};
/** Extra casbin policy lines, written by hand on the advanced view. */
rules: string[];
+ packs: string[];
+ /** "kind, pattern, eft" */
+ guards: string[];
+}
+
+export interface PermissionPack {
+ name: string;
+ guards: string[];
}
/** The permissions of one agent with what the card needs to draw them: the
tool
@@ -400,6 +408,9 @@ export interface AgentPermissionInfo {
groups: ToolGroup[];
model: string;
policy: string[];
+ packs: PermissionPack[];
+ guardModel: string;
+ guardPolicy: string[];
}
/**
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]