Skip to content

5.4 权限引擎(PermissionEngine) ​

本节你会学到

  • 两层模型:先规则引擎 → 只有 ASK 才走 confirm_tool
  • 4 种预设模式(READ_ONLY / WORKSPACE_WRITE / FULL_ACCESS / PLAN)
  • web_fetch 的域名规则语义(allowlist / blocklist、精确 vs 后缀匹配)

PermissionEngine 是 Agentao 的第一道防线——它在工具真正执行前做规则判决,把"明显安全"和"明显危险"两类请求自动处理掉,只让边缘情况进入 confirm_tool()(第 4.5 节)。

两层防御模型 ​

                ┌────────────────────────┐
 LLM 发起工具 ─► │ 1. PermissionEngine   │──┬─ ALLOW → 直接执行
                │   (JSON 规则 + 预设)   │  ├─ DENY  → 直接拒绝
                └────────────────────────┘  └─ ASK   ──┐
                                                       ▼
                                            ┌────────────────────┐
                                            │ 2. confirm_tool()   │
                                            │   (用户 UI 确认)    │
                                            └────────────────────┘

层 1 零延迟(JSON 规则匹配),层 2 秒级延迟(等用户点按钮)。好的配置让 90% 的工具调用根本不打扰用户。

PermissionDecision ​

python
class PermissionDecision(Enum):
    ALLOW = "allow"   # 直接执行
    DENY  = "deny"    # 直接拒绝(Agent 收到 cancelled 字符串)
    ASK   = "ask"     # 走 confirm_tool 问用户

如果所有规则都不匹配(decide() 返回 None),Agent 回退到工具自己的 requires_confirmation 属性决定是否问。

PermissionMode:四种预设 ​

模式写操作ShellWeb适合
READ_ONLY拒(规则无法放行)拒(规则无法放行)问(规则生效)只读探索、审计
WORKSPACE_WRITE允按规则白名单允 / 黑名单拒 / 其余问生产嵌入默认
FULL_ACCESS全允全允全允开发环境 / 完全信任
PLAN拒(仅 plan_* 允)拒(仅 git 等允)白名单允Plan 模式内部使用

切换模式:

python
agent = Agentao(..., permission_mode="workspace-write")  # 构造时建引擎
agent.set_permission_mode("read-only")                   # 返回 PermissionMode.WORKSPACE_WRITE

模式用字符串表示——"read-only"、"workspace-write"、"full-access"、"plan",与 ActivePermissions.mode 的取值相同。PermissionMode 枚举仍然可以用。未知字符串抛 ValueError。运行时可随时切换——下一次工具调用就生效。

优先用 set_permission_mode,而不是 agent.permission_engine.set_mode(...)——后者只切了一半。 read-only 有两个开关:引擎的预设和 ToolRunner.readonly_mode;而引擎按设计 不持有 transport,所以裸调 set_mode 既不发 READONLY_MODE_CHANGED 也不发 PERMISSION_MODE_CHANGED,回放文件里只剩下由此产生的拒绝,没有任何一条说明姿态是 什么时候变的。set_permission_mode 会同时拨动两个开关、发出两个事件(标记 cause="host",从其他入口进来则是 "cli" / "cli-allow-all" / "cli-plan-implement" / "cli-resume" / "acp" / "run"),并返回切换前的模式——类型是 PermissionMode 枚举,不是字符串 (.value 才是字符串)。

权限模式需要引擎。 build_from_environment 总会建一个,除非 permission_mode= 另行指定,它以 workspace-write 启动。既没传 permission_mode= 也没传 permission_engine= 的裸 Agentao(...) 没有引擎:不执行任何规则,调用 set_permission_mode 会抛 ValueError。permission_mode= 接受 "read-only"、"workspace-write" 或 "full-access";不接受 "plan",因为 plan 模式要通过 plan 会话进入。agent 会静默地以该模式启动——起始状态不是一次切换,所以不发事件;构造之后的切换照上文记录。需要 rules= 时,自己构造引擎并改传 permission_engine=。对调用方只有一条规则:你传入的引擎与模式互斥(抛 ValueError)。Agentao(permission_mode=) 构造 PermissionEngine(project_root=working_directory, rules=[]),不读取任何规则文件;build_from_environment(permission_mode=) 把模式应用到工厂从权限文件加载的那个引擎上——那是工厂内部的引擎,不是你传入的——所以用户的规则保留。

"问"由 transport 回答。 上表里所有的"问"都交给 transport.confirm_tool。默认的 NullTransport 对它们一律回答"是",只有 MCP Skills 闸门发起的确认除外;所以在无界面宿主里,"问"就等于"允"。没人可问、又希望拒绝的宿主,应传入 SdkTransport(confirm_tool=lambda *_: False)。

因此在无界面宿主里,"问"的调用都会执行:只读白名单以外的 shell 命令、访问未列出域名的 web_fetch、web_search、写入 .git/、.agentao/ 或疑似凭据的路径。在 NullTransport 下仍能拦住调用的有:

拦截需要引擎吗
只读模式需要。通过 set_permission_mode 或 permission_mode= 设置
引擎的 deny 规则,包括 workspace-write 预设自带的需要
run_shell_command 的硬性命令底线(rm -rf /、mkfs 等)不需要。没有引擎时它也开着(permissions_hardline/)
MCP Skills 闸门,NullTransport 会拒绝它的确认不需要
web_fetch 的 URL 策略(内网和元数据地址)不需要(6.3)

规则 JSON 格式 ​

加载位置:

~/.agentao/permissions.json      ← 用户级(唯一基于文件的来源)

项目级文件被忽略

<cwd>/.agentao/permissions.json 不会被加载——引擎会打 warning 并跳过。一条 checked-in 的 {"tool": "*", "action": "allow"} 会因引擎首个命中即返回而当场作废整个用户策略,所以项目级文件不能授予任何能力。权限是用户/host 的事,不是 cwd 的事 —— 与 OS 权限模型、IDE workspace-trust 同构。如果确需项目感知策略,请由 host 注入:见 4.7.6 active_permissions。

基本结构:

json
{
  "rules": [
    {"tool": "read_file", "action": "allow"},
    {"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"},
    {"tool": "write_file", "action": "ask"},
    {"tool": "run_shell_command", "args": {"command": "rm\\s+-rf"}, "action": "deny"},
    {"tool": "*", "action": "ask"}
  ],
  "shell": { "dialect": "powershell" }
}

文件有两个顶层键。rules 是下文的全部内容;shell(0.4.22 新增,仅 Windows)决定 run_shell_command 用哪个解释器、以及命令地板按哪种语法扫描 —— 见附录 B.3.2,启动与编码见 docs/reference/configuration.zh.md §4。未知的顶层键会报出点名的错误,不会被忽略。

评估顺序(最先匹配者胜):

模式规则顺序
FULL_ACCESS / PLAN预设规则 → 用户 JSON
其他用户 JSON → 预设规则

规则字段详解 ​

tool — 工具名匹配 ​

json
{"tool": "write_file"}     // 精确匹配
{"tool": "mcp_github_*"}    // 通配符(匹配前缀)
{"tool": "*"}               // 匹配所有

args — 参数正则匹配 ​

键是参数名,值是 Python 正则。所有键都必须命中才算规则匹配:

json
{
  "tool": "write_file",
  "args": {
    "path": "^/tmp/safe-dir/"     // path 必须以 /tmp/safe-dir/ 开头
  },
  "action": "allow"
}

多个 args 同时要满足:

json
{
  "tool": "run_shell_command",
  "args": {
    "command": "^docker ",         // 命令以 docker 开头
    "cwd": "^/var/app/"            // 且 cwd 在 /var/app/ 下
  },
  "action": "allow"
}

domain — URL 域名匹配(专为 web_fetch 等) ​

json
{
  "tool": "web_fetch",
  "domain": {
    "allowlist": [".github.com", ".docs.python.org", "r.jina.ai"]
  },
  "action": "allow"
}

匹配语义:

模式含义
.github.com后缀匹配:匹配 github.com 和 api.github.com,不匹配 notgithub.com
github.com精确匹配:只匹配 github.com 本身

一个 domain 规则里可以同时设 allowlist 和 blocklist——两者都算命中(所以要写两条规则区分 allow/deny,而不是一条)。

默认内置的 WORKSPACE_WRITE 预设已经带了一套合理的 allow/block 域名(参见下方)。

主机名怎么取 ​

引擎默认从 url 参数读 URL,要读别的参数就设 domain.url_arg。主机名取自 urlparse(url).hostname(_extract_domain):

  • 不区分大小写;端口会被去掉,所以 docs.python.org:8443 也匹配 .docs.python.org。
  • userinfo 会被去掉:https://github.com@evil.com/ 的主机名是 evil.com,不是 github.com。
  • 没写 scheme 的 URL 按 https:// 解析。
  • 取不出主机名的 URL 不匹配任何 domain 规则,引擎接着看下一条规则。
  • IP 地址写成精确模式(不带前导点):1.127.0.0.1 不会匹配 127.0.0.1。

规则只看模型传进来的那个 URL。重定向的每一跳由请求时的 URL 策略另行检查(6.3 层 2)。

在默认 WORKSPACE_WRITE 规则下:

调用决策原因
web_fetch https://docs.python.org/3/library/os.htmlALLOW后缀匹配 .docs.python.org
web_fetch http://169.254.169.254/latest/meta-dataDENY精确命中 blocklist
web_fetch https://example.com/apiASK没有 domain 规则命中,落到兜底的 web_fetch ask 规则

action — 动作 ​

json
{"action": "allow"}
{"action": "deny"}
{"action": "ask"}

action 和 tool 一样是必填字段。缺了它,规则校验失败(PermissionRuleError;来自文件时是 PermissionConfigError),引擎不会替你补默认值。

预设规则速览 ​

WORKSPACE_WRITE 模式自带规则(源码 agentao/permissions.py::_PRESET_RULES):

json
[
  {"tool": "write_file", "action": "allow"},
  {"tool": "replace", "action": "allow"},
  // 只读 shell 命令白名单(git status/log/diff、ls、cat、echo、pwd、which、head、tail 等)
  {"tool": "run_shell_command", "args": {"command": "^(git (status|log|...)|ls\\b|cat\\b|...)"}, "action": "allow"},
  // 危险命令黑名单(rm -rf、sudo、mkfs、dd if=)
  {"tool": "run_shell_command", "args": {"command": "rm\\s+-rf|sudo\\s|mkfs|dd\\s+if="}, "action": "deny"},
  // 其他 shell 问用户
  {"tool": "run_shell_command", "action": "ask"},
  // 可信域名直接允许 web_fetch
  {"tool": "web_fetch", "domain": {"allowlist": [".github.com", ".docs.python.org", ".wikipedia.org", "r.jina.ai", ".pypi.org", ".readthedocs.io"]}, "action": "allow"},
  // SSRF 目标直接拒绝
  {"tool": "web_fetch", "domain": {"blocklist": ["localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254", ".internal", ".local", "::1"]}, "action": "deny"},
  {"tool": "web_fetch", "action": "ask"},
  {"tool": "web_search", "action": "ask"}
]

SSRF 防护:blocklist 包含常见内网/元数据地址,防止 Agent 被诱导访问内部服务。生产环境建议扩展而非删除这个列表。

程序式定制 ​

把自定义 Engine 传给 Agent:

python
from agentao import Agentao
from agentao.permissions import PermissionEngine, PermissionMode

engine = PermissionEngine(project_root=Path("/data/tenant-a"))
engine.set_mode(PermissionMode.WORKSPACE_WRITE)
# 动态加规则(比如根据租户订阅等级)
engine.rules.insert(0, {
    "tool": "mcp_slack_*",
    "action": "ask" if tenant.free_tier else "allow",
})

agent = Agentao(
    working_directory=Path("/data/tenant-a"),
    permission_engine=engine,
)

从宿主侧读取当前策略 ​

需要在自家 UI 上展示当前策略(或写入审计日志)的宿主,调用 harness 合约里的稳定 getter agent.active_permissions():

python
snap = agent.active_permissions()
# snap.mode            -> "workspace-write"
# snap.rules           -> [...]                 # list[dict],JSON-safe
# snap.loaded_sources  -> ["preset:workspace-write",
#                          "user:/Users/me/.agentao/permissions.json"]

loaded_sources 是稳定字符串标签:preset:<mode>、user:<path>、injected:<name>。(project:<path> 标签已不再发出 —— 见上方 warning。)MVP 不 暴露逐规则 provenance —— 需要规则级 provenance 的宿主应将 loaded_sources 与自己注入的策略元数据组合。

宿主在引擎之上叠加额外策略时(运行期计算的 allowlist、租户级 overlay 等),通过 add_loaded_source(...) 标注自己的 provenance:

python
engine.rules.insert(0, {"tool": "mcp_slack_*", "action": "ask"})
engine.add_loaded_source("injected:tenant-overlay")

snap = agent.active_permissions()
# snap.loaded_sources 中包含 "injected:tenant-overlay"

快照带缓存;缓存在 set_mode() 时失效,在 add_loaded_source(...) 传入新标签 时失效(重复标签会被合并、不触发重建)。直接修改 engine.rules 不会让缓存失效 —— 原地改完后请补一次 set_mode(engine.active_mode)(同模式重设也会清缓存)或 add_loaded_source("injected:<unique-name>") 触发重建。

同一份数据也驱动公共事件流上的 PermissionDecisionEvent.loaded_sources。完整教学(含审计流水线模式)见 4.7 嵌入式 Harness 合约;密集字段速查见 附录 A.10。

典型配置模板 ​

模板 A · 严格生产(客户端产品) ​

json
{
  "rules": [
    {"tool": "read_file", "action": "allow"},
    {"tool": "glob", "action": "allow"},
    {"tool": "grep", "action": "allow"},

    {"tool": "write_file", "args": {"path": "^/workspace/"}, "action": "allow"},
    {"tool": "write_file", "action": "deny"},

    {"tool": "run_shell_command", "action": "deny"},

    {"tool": "web_fetch", "domain": {"allowlist": [".your-company.com"]}, "action": "allow"},
    {"tool": "web_fetch", "action": "deny"},

    {"tool": "*", "action": "ask"}
  ]
}

模板 B · 开发沙箱 ​

json
{
  "rules": [
    {"tool": "run_shell_command", "args": {"command": "^docker |^npm |^python |^node "}, "action": "allow"},
    {"tool": "run_shell_command", "args": {"command": "rm\\s+-rf /|sudo|mkfs"}, "action": "deny"},
    {"tool": "run_shell_command", "action": "ask"}
  ]
}

模板 C · CI / 无人值守 ​

CI 里你没人来点"允许",所以应明确允许/拒绝,杜绝 "ask":

json
{
  "rules": [
    {"tool": "write_file", "args": {"path": "^/tmp/ci/"}, "action": "allow"},
    {"tool": "read_file", "action": "allow"},
    {"tool": "glob", "action": "allow"},
    {"tool": "grep", "action": "allow"},
    {"tool": "*", "action": "deny"}
  ]
}

与 confirm_tool 的协同 ​

decide() 返回 ASK 时,Agent 会调 transport.confirm_tool(...) 问用户。所以你的 UI 只需处理"边缘情况",不必每次工具都弹窗。

验证规则是否生效:

python
from agentao.permissions import PermissionDecision

# 手动测试几个关键场景
for tool, args in [
    ("write_file", {"path": "/tmp/safe.txt", "content": "..."}),
    ("write_file", {"path": "/etc/passwd", "content": "..."}),
    ("run_shell_command", {"command": "rm -rf /"}),
    ("web_fetch", {"url": "http://127.0.0.1:8080"}),
]:
    dec = engine.decide(tool, args)
    print(f"{tool}({args}) → {dec}")

部署前把这段 sanity check 做成单元测试,保证预期的规则都命中。

⚠️ 常见陷阱 ​

上线前先确认这几条

  • ❌ 规则顺序写反了 —— 命中即返回,deny 写在 allow 之后永远不触发
  • ❌ 没有兜底规则 —— 未定义的工具会落到你没审过的默认行为
  • ❌ 正则没转义 —— . 是任意字符匹配,不是字面点
  • ❌ allowlist 写成 "github.com" 想做后缀匹配 —— 没前导点就是精确匹配

下面每一条都附完整修法。

❌ 规则顺序写反了 ​

json
[
  {"tool": "write_file", "action": "ask"},
  {"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"}
]

第一条无条件匹配所有 write_file,第二条永远不会被评估。把更具体的规则放前面:

json
[
  {"tool": "write_file", "args": {"path": "^/tmp/"}, "action": "allow"},
  {"tool": "write_file", "action": "ask"}
]

❌ 没有兜底规则 ​

没写 {"tool": "*", ...} 兜底,未命中的工具会走各自 requires_confirmation——结果可能与你预期不符。生产环境建议明确兜底。

❌ 正则没转义 ​

JSON 里 \ 要写两次:"rm\\s+-rf"。

❌ allowlist 写成 "github.com" 想做后缀匹配 ​

少了前导点就是精确匹配。想匹配所有子域名要写 ".github.com"。

TL;DR ​

  • 两层:规则引擎(零延迟,ALLOW / DENY / ASK)→ confirm_tool(秒级,只在 ASK 时触发)。
  • 起步选预设:WORKSPACE_WRITE 是生产默认;READ_ONLY 用于审计;FULL_ACCESS 仅给可信本地开发;PLAN 用于只读 pre-commit review。
  • 域名规则:".github.com"(前导点)= 后缀匹配;"github.com"(无点)= 精确匹配。默认预设的 web_fetch 黑名单拒绝 localhost、127.0.0.1、0.0.0.0、::1、169.254.169.254、.internal 和 .local。它不含 RFC 1918 网段:私有地址由 URL 策略在发出请求时另行拒绝(6.3 层 2)。
  • 在预设之上叠加规则——为你自己的工具和 MCP server 写规则;默认规则覆盖不到它们。

→ 下一节:5.5 记忆系统