权限模式与规则
四种权限模式在各入口的默认值与真实语义,PermissionService 的判定顺序,规则的结构与匹配算法,审批请求怎样经 broker 交给终端或桌面并与钩子竞速,“始终允许”与 Full access 各存在哪一层,以及 Plan 与 yolo 的例外。
模型每发起一次工具调用,执行器都要先回答一个问题:放行、拒绝,还是停下来问人。ZCode 把这件事拆成三块。apps/zcode-cli/packages/core/src/permission/service.ts 里的 PermissionService 是同步的纯判定,只给出 allow、ask、deny 三种结论(service.ts:59);apps/zcode-cli/packages/core/src/tool/executor/permission-*.ts 把结论落地:加载项目规则、叠加钩子的意见、把询问交给 broker、保存“始终允许”;broker 则由宿主提供,终端 TUI、无头 -p 和桌面与 Web 背后的 ZCode Protocol 服务端各有一份。
权限在工具调用流水线里的位置(输入校验与 PreToolUse 钩子之后、handler 之前)见执行器:调度、审批、超时与结果,Bash 命令怎样被判成“只读”见 Bash:解析、只读判定与后台任务。
四种模式与各入口的默认值
CollaborationMode 有五个值(apps/zcode-cli/packages/contracts/src/interfaces/session.port.ts:32),但运行时真正保存的执行状态只有两个字段:权限档位 mode(build、edit、yolo、auto)和独立的 planEnabled 开关,plan 只在读取旧格式的边界上被接受(packages/shared/src/execution-state.ts:3)。传入 mode: "plan" 时档位保持原值,只把 planEnabled 置为 true(src/execution-state.ts:21),AgentRuntime 构造时就按这条规则归一配置(apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:237)。所以 Plan 其实是叠在某个档位上的只读约束,TUI 只是在 planEnabled 为真时把它显示成 Plan(apps/zcode-cli/packages/cli/src/tui-command-state.ts:48);Plan 与 Goal 也不能同时开启(apps/zcode-cli/packages/core/src/runtime/execution-state.ts:58)。
| 模式 | TUI 里的说明 | 代码里的语义 |
|---|---|---|
build | Ask before each file changes. | 只读工具放行,有副作用的询问 |
edit | Edit selected files or relevant workspace files automatically. | 在 build 之上,工作区文件编辑直接放行 |
plan | Inspect the code and present a plan before editing. | 当前档位加 planEnabled:只读、非破坏性的放行,其余拒绝 |
yolo | Edit and run commands with fewer confirmations. | 普通工具一律放行,交互类与 alwaysAsk 工具除外 |
auto | 不在 TUI 里出现 | 预留未实现,普通工具一律拒绝(service.ts:140) |
说明文字出自 apps/zcode-cli/packages/tui/src/app-mode-command.ts:10。TUI 里 Shift+Tab 按 plan、build、edit、yolo 的顺序轮换(apps/zcode-cli/packages/tui/src/app-mode.ts:5、apps/zcode-cli/packages/tui/src/app-keyboard-helpers.ts:73),/mode 可以直接选。命令行的 --mode 只接受这四个值,大小写不敏感,传 auto 会报错退出(apps/zcode-cli/packages/cli/src/run.ts:133)。根目录 NOTICE 对默认值的说明是(NOTICE.md:11):
共享运行配置默认采用
build权限模式;独立 CLI 通过--prompt执行非交互任务时,未指定--mode会采用yolo。
代码与之一致,但入口之间的差别更细:
| 入口 | 没有显式指定模式时 | 出处 |
|---|---|---|
zcode -p "..." | 固定补 yolo | run.ts:42、run.ts:494 |
zcode --target "..." | 不补默认,走下面的回落链 | run.ts:510 |
zcode、zcode tui | 回落链 | apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:160 |
桌面、Web(经 app-server) | 取 session/create 参数里的 mode,没有再走回落链 | apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3328 |
回落链是:显式模式、本项目上次选过的模式、配置项 permission.mode(默认 build,apps/zcode-cli/packages/contracts/src/config/index.ts:295),见 apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:241 与 apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:123。“上次选过的模式”是每次切换模式时写下的项目级偏好(apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:428)。恢复旧会话时,会话自己保存的执行状态又优先于项目偏好(apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:213)。
配置文件的 permission 段有五个键(apps/zcode-cli/packages/adapters/src/config/schema.ts:13):mode 就是上面说的默认档位;allowedTools、disallowedTools 按精确工具名比较;autoApproveHighRisk 只跳过“高风险要问”那一格,Bash 这类声明了 needsApproval 的工具随后仍会在“有副作用”一格被问到(service.ts:474、service.ts:497);allowMediumRiskInAuto 会传进 PermissionService(create-app.ts:346),但判定逻辑里没有任何地方读它。
各模式下放行、询问还是拒绝
按各工具声明的 readOnly、destructive、sideEffectScope、riskLevel、needsApproval 走一遍判定,常用工具的结论如下:
| 工具 | build | edit | plan | yolo |
|---|---|---|---|---|
只读:Read、Glob、Grep、WebSearch、TodoWrite、Agent、Skill | 放行 | 放行 | 放行 | 放行 |
写:Write、Edit | 询问 | 放行 | 拒绝 | 放行 |
Bash,命令被判为只读 | 放行 | 放行 | 放行 | 放行 |
Bash,其他命令 | 询问 | 询问 | 拒绝 | 放行 |
网络:WebFetch | 询问,预批准域名放行 | 同 build | 放行 | 放行 |
| MCP 工具 | 询问 | 询问 | 未声明 destructiveHint 的放行 | 放行 |
交互:AskUserQuestion | 询问 | 询问 | 询问 | 询问 |
ExitPlanMode | 拒绝 | 拒绝 | 询问(即计划审批) | 拒绝 |
CreateWorkflow、AmendWorkflow、SaveWorkflow | 询问 | 询问 | 询问 | 询问 |
几处需要解释:WebFetch 声明为只读但 needsApproval 为真(apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:201),build 下要问,Plan 只看只读与否,所以反而放行;Bash 的静态声明是 high、system(apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:456),只有只读判定通过时才在运行时改成低风险只读(bash.ts:77);MCP 工具一律 needsApproval: true,只读与破坏性取自服务端给的注解(apps/zcode-cli/packages/core/src/mcp/index.ts:108)。EnterPlanMode 在任何模式下都直接放行(apps/zcode-cli/packages/core/src/permission/plan-mode-policy.ts:23)。
PermissionService 的判定顺序
checkPermission(service.ts:97)从上到下,先命中者定案:
中间最关键的一段是 service.ts:130:
// 声明 alwaysAsk 的工具必须经过用户确认,不能被权限模式的放行分支绕过。
if (capability.alwaysAsk) {
return this.checkAlwaysAsk(context, capability, projectRules, rulePolicy);
}
const planEnabled = context.planEnabled ?? context.mode === "plan";
if (context.mode === "yolo" && !planEnabled) {
return this.allow(context, capability, "mode.yolo", "Yolo mode bypasses permission prompts");
}
if (context.mode === "auto") {
return this.deny(
context,
capability,
"mode.auto.unimplemented",
"Auto mode is reserved but not implemented yet",
);
}
if (this.config.disallowedTools.has(context.toolName)) {
return this.deny(
context,
capability,
"rule.disallowedTools",
`Tool ${context.toolName} is explicitly disallowed`,
);
}yolo 排在禁用名单和项目规则之前,这是有意为之:checkAlwaysAsk 的注释写明它只为 alwaysAsk 工具补一遍硬阻断,不改其他工具的既有优先级,“尤其 yolo 目前先于 disallowedTools 放行这一点”(service.ts:331)。build 规则(service.ts:452)依次是:只读、非破坏且不需审批的放行;critical 询问;high 在没开 autoApproveHighRisk 时询问;作用域为 session 的低风险操作放行;需要审批、有破坏性或副作用作用域不是 none 的询问;剩下的放行。edit 只多一条:权限名是 edit 且作用在工作区的直接放行(service.ts:518)。
判定结果还要经过两道叠加(apps/zcode-cli/packages/core/src/tool/executor/permission-flow.ts:92)。一是 PreToolUse 钩子:钩子说 allow 能把询问变成放行,但抹不掉 alwaysAsk 的询问;钩子说 ask 能把放行变成询问;拒绝则原样保留(apps/zcode-cli/packages/core/src/tool/executor/hook-flow.ts:195)。二是记忆文件:Write、Edit 写进记忆目录的 .md 文件一律放行,连 Plan 的拒绝也会被改写,只有其他拒绝、alwaysAsk、项目 ask 规则和钩子的 ask 保留(apps/zcode-cli/packages/core/src/tool/executor/memory-file-permission.ts:74),记忆本身见项目记忆。
规则的结构与匹配
规则不是 Bash(git status:*) 这样的字符串,而是两个字段的结构:toolName 加可选的 ruleContent(apps/zcode-cli/packages/contracts/src/interfaces/permission.port.ts:19),一套规则集按 allow、deny、ask 分组(permission.port.ts:24)。Claude Code 把工具名和内容写进同一个 Tool(specifier) 字符串(见配置文件),ZCode 则分开存,一条前缀规则写作 toolName 为 Bash、ruleContent 为 git status:*。括号写法只出现在工具禁用名单里(命令行的 --disallowedTools 与协议会话参数),而且括号里的内容会被丢掉,后面细说。
匹配分三步(service.ts:233):
- 工具名:精确相等;
Edit规则同时覆盖Write(service.ts:264);保留键zcode:permission-capability:official_cua(packages/shared/src/zcode-protocol-legacy-types.ts:45)只匹配宿主验证过的官方 Computer Use 工具,同名第三方 MCP 冒充不了(service.ts:274)。 - 取主体:没有
ruleContent的规则匹配整个工具;否则从输入里取第一个字符串字段,顺序是command、url、file_path、path、pattern、patch_text(service.ts:292)。WebFetch例外,主体是domain:加小写主机名(apps/zcode-cli/packages/core/src/permission/rule-matching.ts:19)。 - 比内容(
service.ts:307):
private matchesRuleContent(subject: string, ruleContent: string): boolean {
if (ruleContent.endsWith(":*")) {
const prefix = ruleContent.slice(0, -2);
return (
subject === prefix || subject.startsWith(`${prefix} `) || subject.startsWith(`${prefix}\t`)
);
}
if (ruleContent.includes("*")) {
return wildcardToRegExp(ruleContent).test(subject);
}
return subject === ruleContent;
}:* 结尾是“前缀加词边界”,git status:* 能匹配 git status 和 git status -s,匹配不了 git statusx;其余的 * 转成首尾锚定的通配正则(rule-matching.ts:1);都没有就逐字相等。
Bash 不走这里,而是由工具自带的规则策略判定(apps/zcode-cli/packages/core/src/tool/handlers/bash-command-permission-policy.ts:94):整条命令逐字相等的规则总能命中;命令能被安全解析(没有重定向、没有动态词)时,按 &&、| 等拆出的子命令逐个比,deny 与 ask 规则命中任意一个子命令即成立,allow 规则则要求每个非只读子命令都被覆盖,只读子命令免检;解析不安全时只认逐字相等(apps/zcode-cli/packages/core/src/tool/handlers/bash-command-rule-evaluator.ts:13)。拆分与只读判定的细节归 Bash。
项目规则的位次也要记住:deny、ask 在 Plan 分支之前,allow 在其后,所以“始终允许”解不开 Plan 的只读约束;yolo 在它们之前,所以 yolo 下项目规则都不看。
审批:从 broker 到终端与桌面
判定为 ask 后,执行器先调用工具自带的 prepareApproval(只有工作流一类工具声明了),它可以因为“脚本根本编不过”之类的理由不弹窗、直接交给 handler 回诊断(apps/zcode-cli/packages/core/src/tool/handlers/create-workflow.ts:13),但生成预览出错时一律照问(apps/zcode-cli/packages/core/src/tool/executor/approval-gate.ts:36)。接着生成 perm_ 加 UUID 的请求 ID(permission-flow.ts:160),发出 PermissionRequested 事件,事件里带着建议规则,以及会话存储是否支持 Full access(apps/zcode-cli/packages/core/src/tool/executor/events.ts:136)。然后,PermissionRequest 钩子链与 broker 并发竞速:
注释交代了为什么要竞速:过去钩子链先串行跑完才启动 broker,同步阻塞的钩子挂着时确认窗已经显示,但点击没有归宿,“确认窗永久死亡”(permission-flow.ts:182)。竞速本身(apps/zcode-cli/packages/core/src/tool/executor/permission-responder-race.ts:59):
// broker 先启动:requestPermission 内同步注册应答 deferred,保证确认窗一可见
// 用户点击就有归宿,不给 hook 留任何独占窗口期。
input
.requestBroker(brokerController.signal, () => {
if (settled || brokerController.signal.aborted) return false;
brokerClaimed = true;
hookController.abort();
return true;
})
.then(
(result) => settle(() => resolve({ result, source: "broker" }), hookController),
// ...
input.runHooks(hookController.signal).then(
(decision) => {
// 无决定 = 退赛:不迁移竞速状态,broker 继续单边等待。
if (decision === undefined || brokerClaimed) return;
settle(() => resolve({ result: decision, source: "hook" }), brokerController);
},传给 broker 的 claimResponse 回调用于带副作用的应答:Full access 要先“认领”胜者、中止钩子,再去提交事务。钩子链出错只算退赛,不替用户做拒绝决定(permission-flow.ts:185)。等待没有默认超时:permissionTimeoutMs 不配就不传(apps/zcode-cli/packages/core/src/runtime/types.ts:132)。
broker 有几种实现:
| 宿主 | broker | 行为 |
|---|---|---|
| 没有配置应答方 | DenyPermissionBroker | 运行时的缺省值(agent-runtime.ts:249),一律拒绝,理由 No permission client configured(apps/zcode-cli/packages/core/src/permission/broker.ts:24) |
| TUI | 进程内转发 | 转给审批面板,面板不在时拒绝(tui-prompt-handler.ts:78) |
-p 无头 | createHeadlessPermissionBroker | 只放行 CreateWorkflow、AmendWorkflow,其余委托给拒绝 broker(apps/zcode-cli/packages/cli/src/headless-workflow.ts:31) |
| 桌面、Web | 协议 broker | AskUserQuestion 转问卷,ExitPlanMode 转计划审批,其余转审批卡片(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:43) |
协议侧还有第二层竞速:同一请求同时以旧协议的反向请求 interaction/requestPermission 和 V4 的待处理交互挂出,谁先应答算谁(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-response-race.ts:20);等待期间按同一个请求 ID 重发,间隔从 1 秒起每次翻倍、封顶 10 秒,让从快照恢复的界面重新登记(interaction-broker.ts:41、apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:121、zcode-protocol/server.ts:882);多个客户端同时点,先到先得,晚到的应答按幂等成功收口(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/interaction-background.ts:41);认不出的 optionId 一律按拒绝处理(interaction-broker.ts:191)。
审批选项由 buildProtocolPermissionOptions 生成(apps/zcode-cli/packages/bootstrap/src/permission-options.ts:37):Allow once、Always allow in this project、Deny;工具声明 askOptions.allowAlways: "session" 时第二项换成 Always allow in this session,声明 false 时去掉第二项。在桌面与 Web 上拒绝时,模型收到的工具结果写明工具没有执行、请停下等待指示,填写的反馈接在后面(permission-options.ts:8、permission-options.ts:11)。TUI 的面板只有 Allow once、Always allow in this project、Deny 三项,Esc 等于拒绝(apps/zcode-cli/packages/tui/src/app-approval.ts:49),拒绝理由只是一句 Denied in TUI(app-approval.ts:130);它不处理会话选项,也没有 Full access;CreateWorkflow 与 AmendWorkflow 的确认在 TUI 里被自动放行,注释称之为“记录在案的 CLI 例外”(apps/zcode-cli/packages/tui/src/app-permission.ts:16)。
“始终允许”存在哪一层
| 应答 | 结果里的字段 | 存到哪里 | 何时失效 |
|---|---|---|---|
| Always allow in this project | permissionUpdates | 会话库 local_setting 表,scope 为 project、namespace 为 permission、key 为 ruleset | 仓库里没有找到查看或删除它的命令与界面 |
| Always allow in this session | sessionPermissionUpdates | PermissionService 实例的内存 | 重启、冷恢复、/new |
| PermissionRequest 钩子返回的规则 | permissionUpdates | 同项目规则 | 同上 |
| Full access | 独立命令 | 会话的执行状态与授权回执 | 把模式切回去 |
项目规则的读写在 apps/zcode-cli/packages/core/src/tool/executor/permission-rules-persistence.ts:14,合并时按工具名加内容去重(apps/zcode-cli/packages/core/src/tool/executor/permission-rules.ts:32),落进 local_setting 表(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/local-settings.ts:27),读取时旧的 permission 表仍作兜底(local-settings.ts:21)。会话库默认在 ~/.zcode/cli/db/db.sqlite(apps/zcode-cli/packages/contracts/src/config/index.ts:303)。“项目”指会话的 projectID:工作目录路径转小写,连续的非 [a-z0-9._-] 字符折成一个 -,去掉首尾的 -,只取前 80 个字符(apps/zcode-cli/packages/bootstrap/src/app/paths.ts:29),所以从代码看,前 80 个字符相同的两个深层目录会共用一套规则。
会话规则只服务 alwaysAsk 那道门,普通工具的判定不看它(service.ts:84、service.ts:365);会话选项授予的是整个工具、不带内容,注释给的理由是脚本每次都不同,授权对象只能是工具本身(permission-options.ts:105)。
权限建议怎样生成
确认窗里“始终允许”要保存的规则,就是 PermissionRequested 携带的建议。非 Bash 工具由 buildDefaultPermissionUpdates 生成:工具名,加输入里第一个非空的 command、url、file_path、path、pattern 作为内容(apps/zcode-cli/packages/core/src/tool/executor/permission-suggestions.ts:6),官方 Computer Use 工具则授予上面那个保留键。Bash 的建议来自规则策略(bash-command-permission-policy.ts:135):只为非只读的子命令生成规则,每个子命令求一个稳定前缀再加 :*,例如 npm run build 得到 npm run build:*,python -m pytest 得到 python -m pytest:*,make test 得到 make test:*;前缀求不出、根命令属于 rm、chmod、sh、dd 等高风险名单(bash-command-permission-policy.ts:19)、命令解析不安全或需要超过 5 条规则时,退回逐字保存整条命令,前缀怎样求见 Bash。TUI 与桌面会把其中的 Bash 前缀列在选项旁边,最多 5 条(app-approval.ts:91)。
从代码看,WebFetch 的默认建议把整条 URL 写进 ruleContent,而判定时拿来比的是 domain: 加主机名,两者对不上,这条“始终允许”不会生效;想按域名放行,得有人写 domain:example.com 形式的规则,目前只有 PermissionRequest 钩子能写入。
审批之后输入又被改了
PreToolUse 钩子改写输入发生在判定之前,权限看到的已经是改后的输入。麻烦的是 PermissionRequest 钩子:它可以在应答时带 updatedInput,边批准边改参数,比如把写入目标换个路径。这时执行器先把新输入归一化、按 schema 校验,再用 recheckPermissionHookModifiedInput 以改后的输入重跑一遍 checkPermission 与记忆文件规则(apps/zcode-cli/packages/core/src/tool/executor/permission-input-recheck.ts:24)。结果分三种(permission-input-recheck.ts:65):被拒绝,整次调用拒绝;是普通的询问或放行,钩子本身就算批准人,沿用它的决定;只有撞上项目 ask 规则或指向记忆文件时,才用同一个请求 ID 再问一次用户(permission-input-recheck.ts:88)。用户自己在确认窗里改输入(modify)则只重新归一化和校验,不再重判(permission-flow.ts:415)。
Full access
桌面与 Web 的审批卡片上还有第四个按钮 Full access(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:3146)。它出现需要三个条件:会话存储实现了 commitPermissionFullAccess,请求不是子 Agent 转上来的,工具也没有声明选项策略(interaction-broker.ts:102)。按下后 grantPermissionFullAccess 把本会话切到 yolo,planEnabled 不变,同时把队列里已受理、还没开始的输入也改成 yolo(apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:18),然后当前请求按“允许一次”收口(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/interaction-registry.ts:170)。这些写入在一个 begin immediate 的 SQLite 事务里完成,同一张回执已存在就直接提交返回(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/permission-full-access.ts:17),改写队列的部分在 repositories/permission-full-access.ts:34:
for (const id of input.queueItemIds) {
const row = read.get(id, input.sessionID);
if (!row || typeof row.payload !== "string")
throw new Error(`Pending input unavailable: ${id}`);
const payload = JSON.parse(row.payload) as Record<string, unknown>;
for (const key of ["intent", "conversationInputIntent"]) {
const intent = payload[key];
if (intent && typeof intent === "object" && !Array.isArray(intent)) {
payload[key] = { ...intent, mode: "yolo" };
}
}
write.run(JSON.stringify(payload), Date.now(), id, input.sessionID);
}
saveSessionEntry(db, input.execution);
saveSessionEntry(db, input.receipt);
db.exec("commit");回执 ID 由会话 ID 加交互 ID 拼成(apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:37),类型是 runtime/permission_full_access(apps/zcode-cli/packages/contracts/src/interfaces/permission-full-access.ts:3),重试时按回执里固定的队列范围重放,不会顺手把后来入队的输入也提权(apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:17)。事务已提交而事件没来得及发布时,恢复动作登记在 unpublishedPermissionGrants 里,下一次授权或模式切换会先补发(apps/zcode-cli/packages/core/src/runtime/permission-grant-recovery.ts:3、apps/zcode-cli/packages/core/src/runtime/execution-state.ts:50)。它只改这一个会话,不写项目偏好;恢复会话时读回执行状态,yolo 仍在。代码里没有单独的“撤销完全访问”,把模式切回去就是关闭。老版本客户端若原样回传这个选项的 response,拿到的是一条拒绝(product-projection.ts:3152)。
Plan 的只读约束与草稿例外
Plan 分支(service.ts:404)只放三类:只读且非破坏的工具;非破坏的 MCP 工具;声明 allowedInPlanMode、作用域为会话、非破坏且不需审批的控制动作,目前只有 RespondToCoordinator(apps/zcode-cli/packages/core/src/tool/handlers/respond-to-coordinator.ts:65)。其余一律以 Plan mode only allows read-only, non-destructive tools 拒绝。ExitPlanMode 声明需要用户交互(apps/zcode-cli/packages/core/src/tool/handlers/plan-mode.ts:155),在 Plan 里总是询问,协议 broker 把它转成计划审批问卷,这部分交互见 Todo、提问与 Plan 模式。
从代码看,Plan 对 MCP 工具只看 destructiveHint 注解。浏览器插件启用时由宿主注册的 mcp__node_repl__js 能在本机执行任意 Node 代码,mcp/index.ts 为它设了 high 风险与 system 作用域,却没有设破坏性,宿主工具列表里也没有注解(apps/zcode-cli/packages/node-repl-host/src/server.ts:76),因此它在 Plan 下会被放行而不询问。内置版本的 js 工具权限名是 node_repl,不受此影响。
两个“写入例外”容易混淆。记忆文件的放行连 Plan 也覆盖,前面已经讲过。工作流草稿的免确认则不覆盖 Plan:Write、Edit 的 file_path 落在 <工作目录>/.zcode/workflow-drafts/ 之内时放行(apps/zcode-cli/packages/core/src/permission/workflow-draft-path.ts:73,目录常量见 apps/zcode-cli/packages/contracts/src/tools/saved-workflow.ts:25),但它排在 Plan 分支之后,注释说 Plan 必须继续拦下一切写入,草稿也是写入(service.ts:198)。这条判定只做路径字符串运算,不碰文件系统,拿不到工作目录就不放行;理由是那个目录由工具自己写、自带忽略全部内容的 .gitignore,而让脚本真正跑起来另有 CreateWorkflow 的确认门(workflow-draft-path.ts:52)。
yolo 下仍要确认的
- 需要用户交互的工具:
AskUserQuestion与ExitPlanMode(service.ts:112)。 - 声明
alwaysAsk的工具:CreateWorkflow、AmendWorkflow、SaveWorkflow(apps/zcode-cli/packages/core/src/tool/handlers/create-workflow.ts:352、apps/zcode-cli/packages/core/src/tool/handlers/save-workflow.ts:286)。前两个可以选本会话免确认,修订本会话自己发起、且不是用户亲手停下的 run 也免确认(service.ts:378);SaveWorkflow不给“始终允许”。 - 开着 Plan 的 yolo:
yolo && !planEnabled不成立,照 Plan 规则走。 - 钩子:PreToolUse 钩子的 deny 与 ask 在 yolo 下照样生效。
反过来,yolo 会跳过项目的 deny、ask 规则和配置文件里的 permission.disallowedTools,所以想在 yolo 下禁掉某个工具,要用 --disallowedTools 把它从工具面上拿掉。子 Agent 中,general-purpose 与自定义 Agent 共用父会话的 PermissionService,Explore 另起一份(apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:315),见子 Agent。
--disallowedTools
写法由 extractDisallowedToolsArgs 解析(apps/zcode-cli/packages/cli/src/arguments.ts:127),它在全局参数解析之前先把这个选项摘出来:
- 两种拼写
--disallowedTools、--disallowed-tools,支持--disallowedTools=Bash的等号形式(arguments.ts:135); - 选项后面可以跟多个值,直到下一个以
-开头的参数,一个都没有就报错(arguments.ts:147); - 值里的逗号和空格都是分隔符,但括号里的不算(
arguments.ts:179);web_search会被规范成WebSearch(arguments.ts:222)。
它有三处效果:注册内置工具和 MCP 工具时直接跳过(apps/zcode-cli/packages/core/src/tool/handlers/index.ts:202、apps/zcode-cli/packages/core/src/mcp/index.ts:67),回合里向模型出示工具时再过滤一遍(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:110),并投影进 PermissionService 的禁用名单(apps/zcode-cli/packages/bootstrap/src/app/app-config-options.ts:20)。注册时只取括号前的工具名(apps/zcode-cli/packages/core/src/tool/tool-visibility.ts:5),帮助文本也写明 "Bash(git *)" 会移除整个 Bash,不支持按命令内容匹配(apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:38)。它只作用于本次 -p 或 TUI 进程,不写进任何配置。
下一篇:生命周期 Hooks 与工作区信任——七个事件何时触发、进程钩子的输入输出协议,以及项目目录里的钩子为什么要逐条信任。