权限模式与规则
工具调用前谁来拍板:三种权限模式的真实语义、规则写在哪与怎么写、询问怎样弹到 TUI、“始终允许”记在哪,以及 Bash 命令的分层判定。
模型每发起一次工具调用,都要先过权限这一关:放行、拒绝,或者停下来问用户。MiniMax Code 把这件事拆成三层。packages/agent-modules/permission(包名 @mavis/permission)是纯决策引擎,包描述写明它从不 import 运行时包;packages/local-runtime/src/permissions 管规则存储,并按权限模式处理“询问”;v2 的 LocalTurnPermissionGate 把判定挂进 pi 的工具钩子,把询问交给审批服务,再推到 TUI。
和 OpenCode 的权限系统那种“默认全放行、最后一条匹配说了算”的规则模型相比,这里默认更保守,判定也分了更多层。
怎么用
TUI 里有三种模式(packages/tui/src/application/permission-mode.ts:1),配置里还多一个 off:
| 配置值 | TUI 名称 | 行为 |
|---|---|---|
default | Ask | 判定为询问就弹审批框 |
auto | Auto | 询问先交给云端分类器,它放行就不问;没登录 MiniMax 账号时退化为 Ask |
bypassPermissions | Full access | 所有询问直接放行、不弹框;硬性拒绝照旧 |
off | 不显示 | 跳过整条判定,只保留删除改写 |
- 切换:
Alt+M在 Ask、Auto、Full access 之间循环(permission-mode.ts:35,packages/tui/src/tui/shell/keybindings.ts:414),也可以用/permission status|ask|auto|full(packages/tui/src/tui/commands/catalog.ts:293)。切换写进配置文件的permissionMode(packages/local-runtime/src/api/host.ts:1493),全局、立即生效。缺省值是auto(packages/config/src/config.ts:1708),读不懂的值也按auto处理(packages/local-runtime/src/api/host-helpers.ts:543)。状态栏显示当前模式,Full access 用警示色(packages/tui/src/tui/shell/chrome.ts:586)。TUI 读模式时只认前三种,配置为off时状态栏只显示笼统的 Permissions,按Alt+M只会提示“Permission mode is managed by Desktop”(packages/tui/src/tui/controller/interaction/permission-mode-flow.ts:54)。 - headless:
mcode exec --permission取smart(默认)、full、off,分别对应auto、bypassPermissions、off(packages/tui/src/cli/run-exec-command.ts:129),只覆盖本次运行(packages/tui/src/runtime/lifecycle.ts:385)。headless 宿主不声明审批能力(lifecycle.ts:452),真走到需要问人的地方,审批请求会以HOST_CAPABILITY_UNAVAILABLE失败,模型收到的是一条工具错误(packages/local-runtime/src/api/hosted-agent-permission-capabilities.ts:93)。 - 审批框:三个选项是“1 Allow for this conversation”、“2 Always allow matching actions”、“3 Deny and guide MCode”(
packages/tui/src/tui/features/interaction/permission-picker.ts:82)。选“始终允许”会先列出将要保存的规则请你确认;选“拒绝”可以附一句指导,TUI 把它当成一条新消息发出(packages/tui/src/tui/app.ts:248)。/allow、/always、/deny是不出现在命令列表里的备用写法。 permission配置段(packages/config/src/permission-config.ts:46):policyOwner选最终裁决者,默认core,写别的值都回退到旧的engine(:67);storageWriteVersion选规则文件格式,默认 2;classifierTimeoutMs与userConfirmationEnabled在公开代码里只被解析,门面创建分类器客户端时并不传入超时,固定为 60 秒(packages/local-runtime/src/permissions/facade.ts:419)。
引擎的结构
@mavis/permission 共 45 个文件、16599 行,按职责分成六块:
| 部分 | 行数 | 内容 |
|---|---|---|
| Bash 判定 | 7593 | tools/bash-*.ts 等 15 个文件:命令拆分、包装剥离、写目标、慢命令、首词快速放行、规则匹配 |
| 路径判定 | 2814 | fs-permission.ts、path-capability.ts、fs-checker.ts 等:工作区边界、敏感文件、路径规则 |
| 核心 | 2674 | engine.ts、permission-core.ts、context.ts、types.ts、execution-plan.ts 等 |
| 危险模式 | 1754 | classifier/ 目录:HARD、SOFT 两套危险模式正则,外加是否走云端分类的判断 |
| 本地化与宿主接口 | 1161 | 原因文案、语言检测、configurePermissionHost 与包入口 |
| 云端网关与询问策略 | 603 | Auto 模式调用的网关客户端、对话摘要、modeToAskPolicy |
引擎只给两种工具注册了检查器:bash 用 BashToolPermissionChecker,edit、write、append、read、glob、grep、list 共用 FsToolPermissionChecker(packages/agent-modules/permission/src/tools/index.ts:14)。别的工具走路径能力的通用判断,最后由 permission-core.ts 的 reducePermissionDecision 统一裁决(packages/agent-modules/permission/src/engine.ts:213)。
一次调用的审批链
挂载点:v2 在 pi 的 beforeToolCall 上排了一串钩子,权限闸门排在最后,前面依次是插件的 PreToolUse、Plan 模式与目标预算的策略守卫、“禁用全部工具”开关和扩展钩子(packages/local-runtime-v2/src/service/turn-system/agent-host/execution/plugin-hook-tool-lifecycle.ts:82)。钩子返回 block 时,pi 不执行工具,直接把原因作为错误结果交给模型(third_party/pi-mono/packages/agent/src/agent-loop.ts:720);闸门给的原因以 Permission denied for 或 Permission denied by user for 开头(packages/local-runtime-v2/src/service/turn-system/agent-host/runner/policy/local-turn-permission-gate.ts:701)。packages/agent-extension/src/permission.ts:118 另有一个通用的 permissionExtension,但生产装配里没有调用它。
闸门(local-turn-permission-gate.ts:171):source 为 builtin、builtin-matrix 的产品内置工具(如 Plan 模式的 EnterPlanMode)不过这道闸:只有宿主类型是 cli 时才检查它们(local-turn-permission-gate.ts:573,开关来自 packages/local-runtime-v2/src/service/turn-system/agent-host/native-production-dependencies.ts:229),而命令行版的 TUI、exec、ACP 共用一个嵌入宿主,类型一律报 tui(packages/tui/src/runtime/embedded-host.ts:84)。read、bash 这些本地工具没有 source 标记,照常检查。插件的 PermissionRequest Hook 能在弹框之前代答或改写参数,改写后要重新判定一次(local-turn-permission-gate.ts:425)。
门面(facade.ts:449):权限模式在入口只读一次,整条判定用同一份快照,免得用户中途切模式造成前后不一致。主体在 checkPermissionRaw 里:
- Windows 与 POSIX 的删除安全检查先行;Off 模式在此直接放行(
facade.ts:556),除非命令含删除、需要改写。 acceptEdits是别名,按default处理,启动时为edit、write、apply_patch补上全局放行规则(facade.ts:566、:918)。- 读出全局、Agent、会话三层规则,外加插件 Hook 授予的规则;规则文件损坏时要求询问,Full access 下则直接拒绝(
facade.ts:598)。 - 本地硬检查:访问 UNC 网络共享、递归删除
/或~,在 Ask、Auto、Full access 下都是绕不过的拒绝(facade.ts:628)。 - 引擎判定,再按模式处理询问(
facade.ts:770):Full access 原样返回、由最后一步改成放行;Ask 直接询问;Auto 在托管运行时(已登录)把请求送到/mavis/api/v1/permission/check(packages/agent-modules/permission/src/classifier/cloud-classify-client.ts:51),分类器说放行就放行,说拦截、要确认或超时都转成询问(facade.ts:856)。另有一个dontAsk模式把询问一律变成拒绝(facade.ts:774),它不在 TUI 和配置里,只能由插件 Hook 的setMode设置(packages/local-runtime/src/permissions/plugin-hook-permission-state.ts:73)。
第 5 步里“Full access 不弹框”这条产品承诺收在一个函数里(packages/local-runtime/src/permissions/ask-gate.ts:66):
export function applyAskGate(
verdict: LocalPermissionCheckResult,
mode: PermissionMode,
): LocalPermissionCheckResult {
if (mode !== 'bypassPermissions') return verdict;
if (verdict.behavior !== 'ask') return verdict;
// Preserve `rewrittenInput` (e.g. `rm` → `mavis-trash` rewrite) so
// the downstream tool call still receives the safer payload. Drop
// `ruleContents` — they are an ask-card UI concern and have no
// meaning on an allow verdict.
return {
behavior: 'allow',
reason: BYPASS_ALLOW_REASON,
...(verdict.rewrittenInput ? { rewrittenInput: verdict.rewrittenInput } : {}),
};
}它只把询问改成放行,拒绝原样保留。所以 Full access 下仍会被拦的有:用户写的 deny 规则、HARD 最终拒绝类命令、上面的本地硬检查,以及后面会讲到的慢命令和 rm 以外的删除类命令。
Core 的裁决顺序(packages/agent-modules/permission/src/permission-core.ts:159):整工具或整个 MCP 服务的 deny 规则最先;检查器给出的 deny 其次,而且压过更宽泛的整工具 ask 规则;然后是整工具 ask、MCP 服务 ask、检查器出错、需要交互的工具;接着是检查器标记为 bypassImmune 的询问、内容级 ask 规则、检查器的普通询问;之后 Full access 放行;再往下才是整工具 allow 规则、检查器放行;没注册检查器的工具默认放行,其余一律询问。
Bash 命令怎样判定
evaluateBashStatic(packages/agent-modules/permission/src/tools/bash-permission.ts:727)先对整条命令做几项检查,因为这些形状会跨越 ;、| 等分隔符:
- 字面上的
rm -rf /、rm -rf ~、rm -rf *和 fork 炸弹直接拒绝,任何模式都一样(bash-permission.ts:796,清单在packages/agent-modules/permission/src/classifier/dangerous-patterns.ts:118); - 剥开
bash -c、sudo等包装后命中 HARD 最终拒绝类别的,直接拒绝(bash-permission.ts:827);这些类别包括不可恢复删除、磁盘擦除、勒索特征、数据外传等(dangerous-patterns.ts:190); - 非 Full access 时,
curl … | bash这类管道进 Shell 的形状要询问,但本地临时文件、工作区脚本和python3 -c '…'这类内联字面量可以豁免(bash-permission.ts:877); - 只带
--help、--version之类参数的命令直接放行(bash-permission.ts:1004)。
然后按 &&、||、;、| 拆成子命令逐个判定,汇总时拒绝优先,其次询问,全部放行才放行(bash-permission.ts:1049)。每个子命令按下表的顺序走,先命中者定案:
| 顺序 | 层 | 结果 | 出处 |
|---|---|---|---|
| 1 | 用户 deny 规则 | 拒绝 | bash-permission.ts:1144 |
| 2 | 用户 ask 规则 | 询问 | :1155 |
| 3 | HARD 最终拒绝类别 | 拒绝 | :1172 |
| 3.5 | 无界的全盘扫描,如不带 -maxdepth 的 find / | 拒绝,并提示改用更窄的命令 | :1192 |
| — | Full access 到此为止 | rm 改写为可恢复删除;其他含删除动词的命令拒绝;其余放行 | :1230 |
| 4 | HARD 敏感读取,如读 /etc/shadow | 询问 | :1297 |
| 5 | 用户 allow 规则 | 放行 | :1312 |
| 5.5 | 写入目标在工作区等已授权位置 | 放行 | :1328 |
| 6 | 按子命令分级的危险模式,如 sudo | 询问 | :1341 |
| 6b | 读取环境里的密钥,如 printenv 某个凭据 | 询问 | :1360 |
| 6.5 | source 本地临时文件或工作区脚本 | 放行 | :1390 |
| 7 | SOFT 预扫描 | 询问 | :1402 |
| 8 | rm | 放行并标记为可恢复删除 | :1444 |
| 9 | 首词是只读命令且没有重定向 | 放行 | :1451 |
| 尾 | 都没命中 | 询问 | :1471 |
函数头注释(bash-permission.ts:704)列的层次比代码少了 3.5、5.5、6b 三层。注释里还有一个 auto 模式,会把询问标成 undecided 留给云端分类器;但检查器只以 default 或 bypass 调用这个函数(packages/agent-modules/permission/src/tools/bash-checker.ts:655、:662),Auto 模式下 bash 与 Ask 判得一样,得出的询问再由门面送去分类器。
rm 在第 8 步“放行”,是因为删除已经在执行层变得可恢复:macOS 与 Linux 上 bash 的 PATH 里有一个 rm 垫片,把删除转给 mavis-trash,见文件与 Shell 工具;Windows 上检查器把命令改写成 mavis-trash -- 交给宿主执行(bash-checker.ts:922)。
包装与快照提交的修复
用户规则会同时匹配原命令和剥掉“透明包装”后的命令。透明包装指 xargs、nohup、setsid、command、nice、timeout、parallel、watch,最多剥四层,前面的 KEY=VAL 环境变量前缀也会去掉;sudo、exec、bash -c 不算,因为它们改变了权限或进程语义(packages/agent-modules/permission/src/tools/bash-wrapper-unwrap.ts:17、:50、:302)。
快照提交(#167,标题里的“preserve bash permission policy”)改的正是这里。修复前,只有第 5 步的 allow 规则会看剥掉包装后的命令,第 1、2 步的 deny、ask 只看原文。按当时的代码推演:规则同时有 allow git:* 和 deny git push:* 时,timeout 60 git push 在第 1 步没命中 deny,到第 5 步却被 allow 放行。修复后三类规则共用同一个匹配函数(bash-permission.ts:1136):
// Every rule behavior sees the same raw and transparent-wrapper-stripped
// forms. Otherwise a wrapped command could skip deny/ask and match allow.
const wrapperStrippedCommand = stripTransparentWrappersForRuleMatch(command);
const matchesUserRule = (parsedRule: CompiledShellPermissionRule | undefined): boolean =>
!!parsedRule &&
(matchShellRule(command, parsedRule) ||
(!!wrapperStrippedCommand && matchShellRule(wrapperStrippedCommand, parsedRule)));同一提交还补上了 cloneContextWith 漏抄的 isFile(packages/agent-modules/permission/src/context.ts:227),这个宿主提供的文件探针用于判断 source 的目标是不是真实的本地文件;不过公开仓库里没有调用 applyPermissionUpdate 的地方,这处遗漏在命令行版里碰不到。
规则写在哪、怎么写
规则分三层,各是一份 permission.json(packages/local-runtime/src/permissions/rules.ts:322):全局在数据目录根下,Agent 级在 agents/<名字>/,会话级在 sessions/<会话 ID>/。命令行版的数据目录默认是 ~/.minimax-code(packages/tui/src/runtime/data-dir.ts:21)。判定时三层合并读取(rules.ts:79)。
文件有两种格式(packages/local-runtime/src/permissions/rule-codec.ts:13)。v1 是字符串数组,工具名(内容) 的写法,如 bash(git:*);v2 是默认写入格式,每条规则带结构化的匹配器,kind 取 tool、command、path,路径规则还要列出适用的动作。读取时两种都认,v1 里以 /、~/、./ 或盘符开头的内容被当成路径(rule-codec.ts:276)。
下面是一份示意的 v2 文件:
{
"version": 2,
"allow": [
{ "tool_name": "bash", "matcher": { "kind": "command", "pattern": "git:*" } },
{ "tool_name": "fs", "matcher": { "kind": "path", "pattern": "/Users/me/notes/**", "actions": ["read", "write"] } }
],
"deny": [{ "tool_name": "bash", "matcher": { "kind": "command", "pattern": "git push:*" } }],
"ask": [{ "tool_name": "web_fetch", "matcher": { "kind": "tool" } }]
}- Bash 规则(
packages/agent-modules/permission/src/tools/bash-rule-match.ts:71):git status精确匹配;git:*匹配git本身或以git开头的命令;含*的按通配符匹配、不区分大小写;JSON 数组如["cargo","build"]要求命令解析成单条普通命令,且前几个参数逐个相同。 - 路径规则:
/dir/**管整棵子树,/dir/*只管一层,否则精确匹配;从matchPathRule看,规则里的~不会展开(packages/agent-modules/permission/src/tools/fs-permission.ts:729)。fs是个伞形工具名,一条fs规则同时作用于 read、write、edit、glob、grep、list(fs-permission.ts:92)。 - 整工具规则:不带内容,如上例的
web_fetch;MCP 工具还可以按服务整体放行或拒绝。
回复怎样落盘(packages/local-runtime/src/api/local-permission-approval-service.ts:140):
| 选项 | 写入 | 效果 |
|---|---|---|
| Allow for this conversation | 会话级 allow | 本会话再遇到匹配的调用不再问,新会话会再问 |
| Always allow matching actions | 全局 allow | 所有会话生效 |
| Deny | 会话级 deny | 本会话内同样的调用直接拒绝 |
写的内容来自判定时给出的 ruleContents。Bash 默认存完整参数列表,比如 cargo build --release 存成 ["cargo","build","--release"],参数一变就会再问(bash-checker.ts:331);edit、write 的放行规则则存在伞形名 fs 下(packages/local-runtime/src/api/host-permission-rules.ts:5)。中止回合时,挂起的询问按拒绝收场,但不写规则(local-permission-approval-service.ts:336)。
与 Plan 模式、沙箱的关系
Plan 模式有自己的守卫,排在权限闸门之前(packages/local-runtime-v2/src/services.ts:973):写文件类工具只允许用 write 或 edit 写规范的计划文件,其他一律拒绝(packages/local-runtime-v2/src/service/plan/tool-guard.ts:47)。这个计划文件的路径还会作为“可信精确写入目标”传给权限判定(packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts:268),写它不必询问(fs-permission.ts:956)。Plan 守卫只管写文件类工具,bash 不在其列,照常走权限判定。Plan 模式本身见 Plan 模式与目标。
沙箱与权限各管一段:权限决定这次调用能不能跑、要不要改写,沙箱在内核层限制跑起来的 bash 能碰什么,默认关闭,而且只包 bash。引擎的上下文里留着 sandboxEnabled、autoAllowBashIfSandboxed 两个字段,开启时可以让沙箱里的 bash 跳过整工具的 ask 规则(context.ts:57);但本地运行时构造上下文时没有设置它们,这条豁免在命令行版里不生效。
下一篇:沙箱:sandbox-runtime 的分叉——Anthropic 开源沙箱运行时的分叉,怎样在 macOS 上把 bash 关进 Seatbelt。