跳到主要内容

数据截至 (上游 commit 0004b748b71c)

工具层与权限闸门:模型的手能伸到哪

30 秒导读: 模型本身只会输出文本。让它真能改文件、跑命令、上网,靠的是一层「工具」。 这一章讲 Kilo Code 里 agent 有哪些手(工具怎么定义、从哪来、怎么合成给模型的那张表), 以及 每只手怎么被拴住(一次调用要过几道闸、谁决定放行)。

本章是 会话主循环 的下半身:主循环负责「转圈」,本章负责「转圈时模型伸出来的那只手」。 edit 工具内部的九级降级匹配算法不在这里,见 落到磁盘


1. 先建立直觉:一只手,一道闸

一个编码 agent 想干活,最少需要两样东西。

第一样是手。 模型说「我要读 src/index.ts」,得有人真的去读磁盘、把内容塞回对话里。 这就是工具(tool):一段带名字、带参数 schema、带 execute 函数的代码。

第二样是闸。 模型也可能说「我要 rm -rf /」。所以每只手前面都得有个门卫,问一句 「这个动作放不放行?要不要先问用户?」这就是权限(permission)

Kilo Code 把这两件事拆得非常干净:

角色白话核心文件
工具契约一个工具最少要长什么样packages/opencode/src/tool/tool.ts
工具注册表这一轮到底有哪些工具、给谁packages/opencode/src/tool/registry.ts
每轮桥接把工具翻译成 AI SDK 认识的格式packages/opencode/src/session/tools.ts
权限求解一条 (permission, pattern) 该 allow / ask / denypackages/opencode/src/permission/index.ts
agent 画像这个模式下手能伸多长packages/opencode/src/agent/agent.ts

一句话类比: 工具是系统调用,权限规则表是 /etc/sudoersctx.ask 是那句 sudo。 区别在于——是工具自己主动喊 sudo 的,不是框架替它拦截。


2. 顶层全景:一次工具调用要过四道闸

先看全景再看细节。下面这张图从上到下是时间顺序,每一道闸都可能直接把这次调用打回去。

模型输出 tool call: shell { command: "git commit -m fix" }

┌──────────────────────▼──────────────────────┐
│ 闸 0 · 这只手压根不在桌上 │
│ agent 规则里 tool:* = deny │
│ → 工具根本没进 tools 列表,模型看不见 │
└──────────────────────┬──────────────────────┘

┌──────────────────────▼──────────────────────┐
│ 闸 1 · 参数不合法 │
│ Schema 解码失败 → InvalidArgumentsError │
│ → 作为工具结果回给模型,让它重写 │
└──────────────────────┬──────────────────────┘

┌──────────────────────▼──────────────────────┐
│ 闸 2 · 权限求解(工具自己调 ctx.ask) │
│ allow → 直接过 │ ask → 弹给用户 │
│ deny → DeniedError │
└──────────────────────┬──────────────────────┘

┌──────────────────────▼──────────────────────┐
│ 闸 3 · 沙箱(可选) │
│ 文件系统白名单 + 网络 deny │
└──────────────────────┬──────────────────────┘

真正执行 execute

怎么读这张图: 闸 0 在「拼 request」时就生效,属于静态裁剪——模型压根不知道有这个工具; 闸 1/2/3 在「执行」时生效,属于动态拦截——模型看得见、也调用了,但被挡回去。

四道闸各自落在哪:

在哪实现关键符号
闸 0 静态裁剪packages/opencode/src/session/llm/request.ts:263resolveToolsPermission.disabled
闸 1 参数校验packages/opencode/src/tool/tool.ts:121-132wrap 内的 decodeInvalidArgumentsError
闸 2 权限求解packages/opencode/src/permission/index.ts:187askresolveReply
闸 3 沙箱packages/opencode/src/kilocode/sandbox/policy.ts:622executeTool / executeMcp

3. 工具契约:一个工具最少要长什么样

这一节讲「工具的类型签名」,是后面所有内容的地基。

3.1 四个类型

packages/opencode/src/tool/tool.ts 只定义了四样东西,加起来不到 60 行:

类型是什么位置
Def工具本体:id + description + parameters + executetool/tool.ts:53
Context执行时框架递给工具的「环境」tool/tool.ts:34
ExecuteResult工具的返回:标题 + 元数据 + 文本输出 + 附件tool/tool.ts:46
InvalidArgumentsError参数不合 schema 时的标准错误tool/tool.ts:22

3.2 Context 里只有两个回调,这是设计的要害

Context 大部分字段是只读数据(sessionIDmessageIDagentabortmessages)。 只有两个字段是函数tool/tool.ts:42-43):

metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>

这两个回调就是工具与外界的全部接口

  • ctx.metadata —— 工具向 UI 推流式进度(shell 工具边跑边吐 stdout 就靠它,tool/shell.ts:593)。
  • ctx.ask —— 工具向 权限系统 申请放行。

注意方向:不是框架在工具外面拦截,是工具自己在合适的时机喊一嗓子。 这带来一个直接后果—— 工具作者决定「问什么粒度」。edit 工具问的是「这个文件路径」(tool/edit.ts:129-132), shell 工具问的是「这条命令」和「这些目录」(tool/shell.ts:286-474), task 工具问的是「这个 subagent 名字」(tool/task.ts:125-128)。

3.3 一个最小工具长这样

下面这段演示「工具三件套」怎么拼,看清楚 ctx.ask 出现的位置就够了。

// 示意,非源码
export const TouchTool = Tool.define(
"touch", // 工具 id,也是权限名
Effect.gen(function* () {
return {
description: "创建一个空文件",
parameters: Schema.Struct({ path: Schema.String }),
execute: (args, ctx) =>
Effect.gen(function* () {
// 先申请:permission 名 = "touch",pattern = 具体路径
yield* ctx.ask({ permission: "touch", patterns: [args.path], always: ["*"], metadata: {} })
yield* Effect.promise(() => fs.writeFile(args.path, "")) // 过闸之后才真干活
return { title: args.path, output: "created", metadata: {} }
}),
}
}),
)

重点看两处:ctx.askexecute第一行patterns 传的是具体值(路径), 而 always 传的是用户点「总是允许」时要落盘的规则。这两者分离是后面 §7 arity 的伏笔。

3.4 Tool.define 偷偷做的三件事

工具作者写的 execute 不是最终跑的那个。wraptool/tool.ts:97-147)会把它包一层:

  1. 解码参数Schema.decodeUnknownEffect 失败就转成 InvalidArgumentsErrortool/tool.ts:119-127)。 这个错误类的 message getter 本身就是给模型看的提示词—— "Please rewrite the input so it satisfies the expected schema."tool/tool.ts:30)。
  2. 截断输出:如果工具没自报 metadata.truncated,统一走 truncate.output,超长输出落盘、 在结果里留 outputPathtool/tool.ts:129-142)。
  3. 打 spanTool.execute 这个 trace span 带上 tool.name / session.id / message.idtool/tool.ts:143)。

还有个小性能细节值得学:decode 闭包在 init 阶段编译一次而不是每次调用都建,注释里明说了 decodeUnknownEffect 每次调用会分配新闭包(tool/tool.ts:106-109)。


4. 工具从哪来:三个来源,合成一张表

这一节回答「模型这一轮看到的工具列表,是怎么攒出来的」。答案在 tool/registry.ts

4.1 三个来源

内置工具(源码里写死) ┐
config 目录里的 .ts ├──► ToolRegistry.all() ──► ToolRegistry.tools(model, agent)
plugin 提供的工具 ┘ builtin + custom 再按模型/供应商过滤一遍

内置清单registry.ts:283-306 里那个数组字面量。下表按数组里的真实先后顺序排(顺序本身有意义: 它决定了工具在给模型的那张表里的排列,也决定了 Kilo 追加的工具插在哪):

工具 id干什么门控条件
invalid模型叫了不存在的工具时的兜底恒在:285
question反向向用户提问client ∈ {app, cli, desktop, vscode} 或 enableQuestionToolregistry.ts:248:286
shell read glob grep跑命令 / 读文件 / 找文件 / 搜内容恒在:287-290
edit write改文件edit 与下面的 patch 互斥,见 §4.4:291-292
task派生 subagent恒在:293
fetch抓网页恒在:294
todo待办清单恒在:295
search搜网数组里恒在,但另有供应商门控,见 §4.4:296
repo_clone repo_overview克隆并速览外部仓库flags.experimentalScout:297
skill加载 skill恒在:298
patch (apply_patch)补丁式改文件edit 互斥,见 §4.4:299
plan (plan_exit)结束规划模式恒在:300
suggest给用户建议选项client ∈ {cli, vscode}:301
Kilo 追加的一批见下段各自门控:302
lsp语言服务器查询flags.experimentalLspTool:303

Kilo 自己追加的那一批由 KiloToolRegistry.extrakilocode/tool/registry.ts:191-231)展开, 位置在 suggest 之后、lsp 之前:codebase_search(需 experimental.codebase_search)、 semantic_search(需索引就绪)、recallbackground_process(cli/vscode)、 agent-manager 系列与 notebook 系列(仅 vscode,notebook 还需 experimental.native_notebook_tools)。

4.2 config 目录里的自定义 .ts 工具

Kilo 会扫每个 config 目录下的 {tool,tools}/*.{js,ts},动态 import 进来(registry.ts:220-234):

  • 文件名是命名空间tools/db.ts 里的 default 导出 → 工具 id db;导出名 query → id db_query
  • Windows 上用 pathToFileURL 转成 file:// 再 import,否则 Node 拒收绝对路径(注释在 registry.ts:227-228)。
  • 判定「这是不是一个工具」靠鸭子类型:有 args + description + execute 三个字段(registry.ts:466isPluginTool)。

4.3 plugin 工具与「Zod ↔ Effect Schema」的边界

plugin 生态对外暴露的是 Zod 参数,而 registry 内部是 Effect SchemafromPluginregistry.ts:162-218)就是这个阻抗匹配层,干三件事:

  1. schema 转换:全是 Zod 就 z.object(args) 再转 JSON Schema;否则退回 legacyJsonSchema 把每个条目当成裸 JSON Schema 片段(registry.ts:168-174)。
  2. ask 桥接:宿主的 ctx.ask 是 Effect,plugin 要的是 Promise,用 EffectBridge 转一层, 注释点明理由是「保住 context」(registry.ts:182-188)。
  3. 补齐 truncate:plugin 返回的裸字符串也要过统一截断(registry.ts:195-205)。

一个真实踩坑留在注释里:def.args 为 undefined 时旧代码写 z.object(def.args),Zod 静默容忍, 1.14.49 之后统一归一成 {}registry.ts:165-167,issue #27451 / #27630)。

4.4 tools():每轮按模型再筛一遍 + 动态改描述

ToolRegistry.toolsregistry.ts:357-408)是每轮真正被调用的入口,它做四件事。

一、按供应商筛 websearch。 webSearchEnabled 要求 providerID 是 kilo,或者开了 Exa / Parallel 标志 (registry.ts:68-73)。

二、editpatch 二选一。 判据是模型 id:含 gpt- 且不含 oss / gpt-4 就用 apply_patch, 否则用 editregistry.ts:363-369)。这是为 OpenAI 系模型偏好 patch 格式做的适配。

三、跑 plugin 钩子 tool.definition,允许 plugin 改描述和 schema(registry.ts:383)。

四、给两个工具拼动态描述——这是很值得学的一招:

工具拼进去什么函数
task当前 agent 有权调用 的 subagent 名单describeTaskregistry.ts:342-355
skill当前 agent 可见 的 skill 名单describeSkillregistry.ts:323-340

describeTask 里那句过滤是精髓:它用 Permission.evaluate("task", item.name, agent.permission) 把被 deny 的 subagent 从描述文本里就剔掉(registry.ts:345-346)—— 模型连它的存在都不知道,而不是叫了之后被拒。describeSkill 同理, 底层是 Skill.availableskill:<name> 规则过滤(skill/index.ts:373-379)。


5. 每一轮桥接给模型:SessionTools.resolve

上一节产出的是 Kilo 自己的 Tool.Def。模型那边要的是 AI SDK 的 tool()。 这一节的转换发生在 packages/opencode/src/session/tools.ts:49,每轮循环调一次。

5.1 主线

ToolRegistry.tools() ──► 逐个包成 AI SDK tool ──┐
├──► Record<string, AITool>
MCP.tools() ──► 逐个改写 + 包一层 ────┘ 交给 LLM 请求

包装体里发生的事(session/tools.ts:152-201):

  1. JSON Schema 按模型改写ProviderTransform.schema(model, ToolJsonSchema.fromTool(item)):79)。
  2. 构造 Tool.Contextmetadata 接到 processor,ask 接到 KiloSessionPrompt.askPermission:48-71)。
  3. 前后各触发一次 plugin 钩子tool.execute.before / tool.execute.after:87 / :104)。
  4. 包进沙箱SandboxPolicy.executeTool(ctx.sessionID, item, item.execute(args, ctx)):93)。
  5. 附件补 id:工具产出的 attachments 在这里补上 PartID / sessionID / messageID:97-102)。

5.2 ProviderTransform.schema:同一个工具,不同模型看到不同 schema

各家模型对 JSON Schema 的容忍度差异极大,provider/transform.ts:1817 专门做兼容:

目标改写动作位置
Moonshot / Kimi$ref 节点只保留 $ref(它拒绝同级 description);tuple 式 items 数组压成单个 schematransform.ts:1841-1857
Google / Gemini整数 enum 转字符串 enum(顺带把 type 改成 string);required 只保留真实存在的字段;数组必须有 items;非 object 类型上删掉 properties/requiredtransform.ts:1859-1958

生成侧 ToolJsonSchema.fromSchematool/json-schema.ts:8)还会先做一轮通用规整: 内联 $ref、干掉 additionalProperties: true、把 anyOf: [T, null] 里的 null 剥掉(对非必填字段)、 给无上界的 integerMIN/MAX_SAFE_INTEGER。结果按 Schema 对象做 WeakMap 缓存(json-schema.ts:6)。

5.3 MCP 工具:统一接入 + 输出转 attachment

MCP 工具走另一条支路(session/tools.ts:457-543),差别有三处。

一、权限是统一的、粗粒度的。 每个 MCP 工具调用前无条件来一次 ctx.ask({ permission: key, patterns: ["*"], always: ["*"] })session/tools.ts:486)—— key 就是 <服务器名>_<工具名>mcp/catalog.ts:117-119sanitize + toolName)。默认规则侧由 getMcpRules 为每个已配置的 MCP server 生成一条 <server>_*: "ask"kilocode/agent/index.ts:359-366)。

二、输出要从 MCP 的 content 数组翻译成 Kilo 的 output + attachmentssession/tools.ts:508-543):

MCP content 类型落到哪
text拼进 output 文本
image转成 data: URL 的 FilePart 附件
resourcetext拼进 output
resourceblob转成附件,filenameresource.uri

三、远程 MCP 会被打标。 type === "remote" 的服务器,其工具对象上会盖一个 SandboxNetwork.remote 符号(mcp/index.ts:725),沙箱因此知道它是「委托出去的网络权限」。


6. 权限求解:一张规则表,四种叠加

这是本章最核心的机制。先讲数据模型,再讲怎么算,最后讲怎么问。

6.1 数据模型:三元组

Rule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
Ruleset = Rule[]

定义在 packages/schema/src/v1/permission.ts:19-24Action 来自同文件 :16(当前 commit 起这些类型从 permission/index.ts 迁到了 schema 包,core 与 opencode 只做 re-export)。

  • permission 通常等于工具 idshelleditread…),也有几个虚拟名: external_directory(访问工作区之外的目录)、doom_loop(重复调用检测)。
  • pattern 是通配串。匹配用 Wildcard.matchpackages/core/src/util/wildcard.ts:3): *.*?.,Windows 上大小写不敏感,且有一条特判—— 以 " .*" 结尾时改成 "( .*)?",好让 git status * 也能匹配光秃秃的 git status

6.2 evaluate:最后一条命中的规则赢

基础求值只有一行(V1 版在 opencode/src/permission/index.ts:102-111;core 包另有 V2 版 packages/core/src/permission.ts:76):

rulesets.flat().findLast((rule) =>
Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)
) ?? { action: "ask", permission, pattern: "*" }

两个要点:findLast 意味着后写的规则覆盖先写的(所以 merge 就是简单的 flat(), 用户配置永远排在默认值后面);没有任何规则命中时默认是 ask,不是 allow 也不是 deny。

6.3 resolve:agent 规则 vs 用户已保存规则

Kilo 在 evaluate 之上加了一层 resolvepermission/index.ts:115-135),因为有两类规则要合并:

名字来自哪直觉
baseagent 的 ruleset(ruleset 参数)「这个模式本来允许你干什么」
saved全局已批准 + session 级(overrides「用户之前点过『总是允许』」

合并的优先级(读作一串 if):

base = deny → deny (agent 的禁令最硬)
saved = deny → deny (用户明确禁了)
base = ask + saved = allow
且 saved.pattern 落在 base.pattern 作用域内 → allow (提升)
base = ask(其他情况) → ask
base = allow + saved = allow → allow

那个「作用域内」的判定是 Wildcard.match(saved.pattern, base.pattern)permission/index.ts:130)—— agent 那条 ask 规则的 pattern 必须能匹配住已保存 allow 的 pattern。 用途:防止一条无关的宽 * allow 把一条精确的 ask 规则顶掉。

resolve 还挂了两个 Kilo 专属钩子:

  • ReadPermission.hardenkilocode/permission/read.ts:11-18):read 权限下, 如果命中的是一条宽规则permission === "*"pattern === "*")却指向 *.env / *.env.*, 就把 allow 强行降级成 ask。.env.example 豁免。
  • external_directory 特判kilocode/permission/external-directory.ts):求值前先剔除 「*/*/deny」这类兜底通配规则,免得它误伤目录判定。

6.4 ask:从工具喊一嗓子到用户点按钮

ctx.ask(req)


KiloSessionPrompt.askPermission ← 现算 agent + session 的 ruleset
│ ruleset = agent.permission + guardPermissions
│ hardRuleset = hardPermissions(仅 plan/ask/architect)

Permission.ask
│ ① hardRuleset 有 deny → DeniedError(保存的 allow 也救不了)
│ ② resolve 出 deny → DeniedError
│ ③ 全部 allow → 直接返回,不打扰用户

④ 发 permission.asked 事件 + 挂一个 Deferred,阻塞等 reply

代码分别在 kilocode/session/prompt.ts:251-279(组装 ruleset)和 permission/index.ts:187-284(求解 + 挂起)。

用户的三种答复Replypackages/schema/src/v1/permission.ts:38):

回复效果位置
once解开这一次的 Deferred,什么都不落盘permission/index.ts:335-336
alwaysrequest.always 里每个 pattern 写成 allow 规则,进内存 approved 并写全局 config:345-367
reject当前请求失败,且同 session 所有 pending 请求一起 reject:319-336

三个不显然的细节:

  1. reject 会级联。 用户拒一次,同 session 排队中的其他权限请求全部被拒(:325-334)。 直觉是:用户已经喊停了,不该再连弹五个框。
  2. always 之后会「排水」。 drainCovered 把 pending 队列里已被新规则覆盖的请求自动放行 (:356),省掉重复弹窗。
  3. 配置文件永远问。 ConfigProtection.isRequest 命中时(.kilo/.kilocode/kilo.jsonAGENTS.md 等,kilocode/permission/config-paths.ts), 即使规则算出来是 allow 也强制转成 ask(:271-273),且 always 被降级成 once:342), 同时往 metadata 里塞 disableAlways 让 UI 藏掉「总是允许」按钮(:285-289)。

两种失败分得很清楚,因为它们回给模型的话术不同:

错误语义模型看到的话
RejectedError (:90)用户当场拒了"The user rejected permission to use this specific tool call."
DeniedError (:104)规则表禁止"The user has specified a rule which prevents you…" + 相关规则 JSON
CorrectedError (:96)拒了并留了反馈拒绝 + 用户那句话

DeniedError 会把命中的规则子集序列化进去(subset:201-203), 让模型知道「不是你不该干,是规则不让」——这比一句冷冰冰的 denied 有用得多。

6.5 两层 ruleset:全局 approved vs session 级

State 里有两份(permission/index.ts:96-100):

  • approved: Rule[] —— 项目级已批准规则,从 SQLite 的 PermissionTable 读出来(:223-226)。
  • session: Record<string, Ruleset> —— 按 sessionID 隔离的临时规则。

allowEverything:414-458)就是靠这两层实现「本次会话内全放行」和「永久全放行」两种档位: 传 sessionID 就写进 session[sessionID],不传就 push 进 approved

6.6 额外的一道闸:doom_loop

doom_loop 是唯一一个不属于任何工具的权限名——它是主循环侧的保险丝:检测到模型反复调用同名 且参数逐字相同的工具时,借这张规则表向用户问一句,默认规则是 doom_loop: "ask"agent/agent.ts:143)。 检测阈值与处置细节见 会话主循环 §4.4


7. arity:把 git commit -m x 归约成人能看懂的 git commit

7.1 要解决的小问题

shell 工具的权限 pattern 是整条命令。用户点一次「总是允许」,如果落盘的规则是 git commit -m "fix typo" *,那下次换个 commit message 就又要问一遍——毫无意义。

反过来,如果一律归约成第一个词 git *,那 git push --force 也被顺手放行了——太危险。

所以需要一张「这个命令的语义前缀有几个 token」的表。

7.2 prefix 算法

packages/opencode/src/permission/arity.ts:1-9,九行:

for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity) // 最长匹配前缀赢
}
return tokens.slice(0, 1) // 兜底:只取第一个词

配套的 ARITY 表(arity.ts:24-161)有约 140 条,规则写在表头注释里:flag 不算 token,只有子命令算只有当更长前缀的 arity 与短前缀不同时才单独列

输入命令命中的表项归约结果落盘规则
touch a.txttouch: 1touchtouch *
git commit -m "fix"git: 2git commitgit commit *
git stash popgit stash: 3git stash popgit stash pop *
npm run devnpm run: 3npm run devnpm run dev *
docker compose up -ddocker compose: 3docker compose updocker compose up *
python script.py无(表里 python: 2python script.pypython script.py *

git 是 2 但 git stash 是 3,正是因为 git stash popgit stash list 危险性差得远。

7.3 落点

shell 工具解析完命令树后,对每个命令节点做两件事(tool/shell.ts:407-408):

scan.patterns.add(source(node)) // 精确 pattern:原样整条命令
scan.always.add(BashArity.prefix(tokens).join(" ") + " *") // always 规则:归约后 + " *"

于是 ctx.ask 收到的是:patterns = 这次要判的原始命令always = 用户点「总是」时写进 config 的人类可读规则§3.3 里埋的那个伏笔,就在这里兑现。

顺带一提,asktool/shell.ts:286-476)其实会问两次:先按 scan.dirsexternal_directory (命令碰到了工作区外的路径),再按 scan.patternsbash。而 scan.access 会在遇到重定向 或非只读命令时标记为 "unknown",从而不再把「这是只读命令」这个元信息传给 UI(tool/shell.ts:384 一带,file_redirect/非只读命令置 "unknown")。


8. agent 与权限:谁决定这只手有多长

8.1 agent 就是一张 ruleset

Agent.Info 里有一个 permission: Permission.Ruleset 字段(agent/agent.ts:56)。 切模式 = 换一张规则表,仅此而已。

规则表按顺序叠三层(agent/agent.ts:178-189 是典型例子):

baseDefaults ← 源码写死的兜底(agent/agent.ts:151-172)
+
该 agent 的专属补丁 ← 例如 plan 的 edit: "*"=deny
+
user ← 用户 config 里的 permission(永远最后,所以永远能覆盖)

baseDefaults 值得看一眼(agent/agent.ts:141-162):整体 "*": "allow", 然后逐个打洞——doom_loop: askquestion/plan_enter/plan_exit/repo_clone/repo_overview: denyread*.env 系列改 ask、external_directory 默认 ask 但白名单目录(截断输出目录、 skill 目录、全局 config 目录)allow。

Kilo 再打一次补丁(KiloAgent.preparekilocode/agent/index.ts:374-386):加上 bash 的 只读白名单和 recall: "ask"

8.2 内置 agent 一览

agentmode权限特征位置
build / codeprimarydefaults + question/suggest/plan_enter allowagent/agent.ts:174-192
planprimaryedit: "*"=deny,只放行 plans 目录下的 *.mdagent/agent.ts:193-218
generalsubagentdefaults + todowrite: deny:193-206
exploresubagent"*": deny 起步,只放行 grep/glob/list/bash/read/webfetch/websearch:207-229
scoutsubagent同 explore + repo_clone/repo_overview,多一个 repos 缓存目录:230-259(需实验标志)
compaction / title / summaryprimary hidden"*": deny,纯文本生成:260-305

Kilo 在这之上再改一轮:build 改名 code,新增 debug/orchestrator/ask, 给 plan 换成 readOnlyBash 白名单(KiloAgent.patchAgentsagent/agent.ts:337)。

readOnlyBashkilocode/agent/index.ts:63-154)本身就是一份很好的抄作业材料: "*": "deny" 打底,逐条放行 cat * ls * grep * git log *… 然后再用一批 deny 兜住注入面—— *|**;**&&**$(**`**>**<(* 全部 deny。 连 sort -o *(能写文件的只读命令)都单独 deny 掉了。

最后 hardenSystemAgentskilocode/agent/index.ts:452-462)在所有 config 合并之后再跑一次, 把 title / summary / compaction 这类系统 agent 的 ruleset 整个换成硬规则—— 用户配置改不动它们。

8.3 plan / ask 模式的「硬化」:两条防线

问题:plan 模式禁止改文件。但用户在 session 里点过一次「总是允许 edit」, session 级规则会不会把 plan 的禁令顶掉?

Kilo 的答案是两条独立防线(都在 kilocode/session/prompt.ts):

防线一:guardPermissions:136-147 —— 只对 ask / plan / architect 生效。 它把 session 规则、agent 规则、再加一遍 session 里的 deny 规则依次拼起来。 因为 evaluatefindLast,「再加一遍 deny」等于把 deny 挪到最后,从而胜过任何 allow。

防线二:hardPermissions:149-152 —— 同样只对这三个模式生效,直接把 agent ruleset 原样作为 hardRuleset 传下去。Permission.ask 里对它的处理是先于一切的 vetopermission/index.ts:221-223):

if (veto(request.permission, pattern, hardRuleset))
return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) })

注释写得直白:saved/session approvals cannot override hard Ask/Plan denials

还有一条更早的闸resolveToolssession/llm/request.ts:263-269)在拼 request 时, 用 Permission.disabled 把「规则是 pattern === "*"action === "deny"」的工具整个从列表里删掉disabled 里还有个映射——edit/write/apply_patch 三个工具共用 edit 这一个权限名 (opencode/src/permission/index.ts:506edits = ["edit", "write", "apply_patch"]),所以一条 edit: deny 能同时干掉三个工具。

plan 模式对应的正向出口是 plan_exit 工具(kilocode/tool/plan.ts:19-59):它不改任何东西, 只是解析出计划文件路径、返回 "Planning complete",由主循环据此判断该不该弹「继续 / 完成」的追问 (shouldAskPlanFollowupkilocode/session/prompt.ts:117-126)。

8.4 subagent 继承什么

deriveSubagentSessionPermissionpackages/opencode/src/agent/subagent-permissions.ts:17-28)明确列了三条:

  1. 父 agent 的 edit 类 deny 规则——注释点出了原因(issue #26514): plan 模式的禁令挂在 agent ruleset 上而不是 session 上, 若 subagent 只继承父 session 的权限,就会静默绕过 plan 模式。
  2. 父 session 的 deny 规则 + 所有 external_directory 规则。
  3. subagent 自己没显式许可的话,默认补上 todowrite: denytask: deny(防止无限套娃)。

9. 扩展面:外挂进来的手

前面讲的都是「源码里有的手」。这一节讲四种从外面接进来的。

9.1 MCP:把别人家的工具接过来

packages/opencode/src/mcp/index.ts 是一个完整的 MCP 客户端管理器:连接、状态机、 工具/prompt/resource 拉取、OAuth。对本章而言只要抓住三点:

  • MCP.tools():695-731)把每个已连接服务器的工具展平成 <sanitize(服务器名)>_<sanitize(工具名)> 的扁平字典。
  • 认证走标准 MCP OAuth:McpOAuthProvidermcp/oauth-provider.ts:26)实现了 SDK 的 OAuthClientProvider 接口,token / clientInfo / codeVerifier / state 全部落在 Global.Path.data 下的 mcp-auth.json,并用文件锁保护并发写(mcp/auth.ts:37-38)。
  • 远程服务器的工具被打上 remote 标记,供沙箱识别(见 §9.4)。

9.2 skills:把「一段说明书」当工具加载

skill 是一个 SKILL.md 加一堆附属文件。加载它的是 skill 工具(tool/skill.ts:14-90):

  1. skill.require(name) 取出内容,找不到就直接 die。
  2. ctx.ask({ permission: "skill", patterns: [name], always: [name] }) —— 按 skill 名逐个鉴权:29-34)。
  3. 输出包在 <skill_content name="..."> 块里,附上 base 目录和最多 10 个同目录文件路径的采样列表(:57-64)。

内置 skill(location === Skill.BUILTIN_LOCATION)没有磁盘目录,走精简分支(tool/skill.ts:37-52)。 skill 的来源包括本地目录扫描和远程 index 拉取(skill/discovery.tspullindex.json 下载并缓存)。

9.3 plugin:能加工具、也能改工具

plugin 系统(plugin/index.ts:133)在 instance 初始化时加载两类插件:内置的(internalPlugins, 各家 auth 插件)和用户配置的 plugin_origins(走 npm 安装 + 动态 import,plugin/loader.ts:211)。

对工具层,plugin 有四个切入点:

钩子时机用途
tool 字段注册时直接新增工具(registry.ts:236-241
tool.definition每轮拼表时描述 / schema(registry.ts:383
tool.execute.before执行前改参数(session/tools.ts:169
tool.execute.after执行后改结果(session/tools.ts:188

flags.pure 会跳过所有外部插件;flags.disableDefaultPlugins 跳过内置插件(plugin/index.ts:175:187)。

9.4 sandbox:文件系统 + 网络的最后一道物理闸

权限系统是逻辑闸(工具自觉喊 ask)。沙箱是物理闸(进程级隔离), 在 kilocode/sandbox/policy.tsnetwork.ts

profilepolicy.ts:81-118)定义了可写目录白名单——项目目录 + 各个 Global 目录, 显式 denyWrite 掉沙箱自己的存储,denyNames: [".git"],并把 TMPDIR 系列环境变量指到托管临时目录、 把 KILO_SERVER_PASSWORD 之类敏感变量从环境里删掉。

网络策略用一个「谁需要显式网络许可」的判定(network.ts:26-32):

工具类别是否需要 assertNetwork理由
内置工具(有 Builtin 标记)走统一的受控 HTTP 层
内置工具但在 opaque 名单里SDK 自己发请求,绕过受控层
自定义 / plugin 工具来源不可信
远程 MCP(有 Remote 标记)「委托出去的权限」

opaque 名单目前只有三个:codebase_searchsemantic_searchlspkilocode/sandbox/network-tools.ts), 每条还带一句为什么("opaque SDK traffic is denied by the common executeTool network boundary")。

assertNetwork 本身(packages/kilo-sandbox/src/network.ts:93-101):没有活跃 profile 就放行; profile 是 proxy 模式或配了 allowedHosts 就报 unsupported;mode 是 allow 就放行;否则 deny。

一个安全细节:secure()policy.ts:25-28)——只要没设 KILO_SERVER_PASSWORD, 沙箱快照就被强制改成 enabled: true, mode: "deny"。也就是无密码的服务端场景默认最严


10. 巧妙之处(可以直接抄走的)

一、把「问什么」交给工具,把「答不答应」交给规则表。 ctx.ask 只是一个回调, 工具决定粒度(路径 / 命令 / subagent 名 / skill 名),规则表只管求值。 两边靠 (permission, pattern) 这个二元组解耦。依据:tool/tool.ts:43permission/index.ts:187

二、arity 表:让「总是允许」落成人能读懂的规则。 精确 pattern 用来判这一次, 归约后的短前缀用来落盘。patternsalways 是两个独立字段,正是为此。 依据:permission/arity.ts:1tool/shell.ts:407-408

三、被禁的能力不出现在描述里。 describeTaskPermission.evaluate 过滤 subagent 名单, 模型连它存在都不知道。比「调用后被拒」省一轮往返,也少一次幻觉来源。依据:registry.ts:345-346

四、findLast + flat() = 零成本的规则优先级。 merge 就是数组拼接, 「后写的赢」这一条规则支撑了 defaults → agent 补丁 → 用户配置的三层覆盖。 而 guardPermissions 只用「把 deny 再追加一遍」就实现了硬化。依据:opencode/src/permission/index.ts:102-111kilocode/session/prompt.ts:234-238

五、错误消息就是提示词。 InvalidArgumentsError.message 直接写着让模型重写输入; DeniedError.message 把命中的规则 JSON 附上。错误类型不是给日志看的,是给模型看的。 依据:tool/tool.ts:25-32InvalidArgumentsError.message)、packages/core/src/v1/permission.ts:21-26DeniedError.message)。

六、配置文件的写权限永远不能被「总是允许」。 因为一旦模型能改 .kilo/, 它就能改权限规则本身——这是提权路径。ConfigProtection 把 always 强制降级成 once。 依据:permission/index.ts:338-341(protected 请求直接 return、不落 always)、kilocode/permission/config-paths.ts

七、只读 bash 白名单要连同注入面一起 deny。 readOnlyBash 除了正向白名单, 还 deny 掉 |;&&$( )、反引号、><( )。光有白名单没用—— cat foo && rm -rf / 会命中 cat *。依据:kilocode/agent/index.ts:112-135


11. 边界与局限(诚实说)

  • 权限是「工具自觉」的。 框架不在 execute 外面强制拦截;一个自定义工具完全可以不调 ctx.ask 就去写磁盘。沙箱是唯一不依赖自觉的一层,而它默认关闭(cfg.experimental.sandbox ?? falsepolicy.ts:141)。
  • ARITY 表是人(LLM)手工生成的清单,注释里保留了生成提示词(arity.ts:11-23)。 表里没有的命令一律退化成「取第一个 token」,冷门 CLI 的 always 规则可能过宽或过窄。
  • 通配匹配不是路径感知的。 Wildcard.match 就是把 * 翻成 .* 的正则, * 会跨目录分隔符。写 pattern 时要自己当心。
  • MCP 工具的权限粒度只有工具级。 每次调用问的是 patterns: ["*"]session/tools.ts:486), 没法按 MCP 工具的参数细分。
  • editpatch 的选择靠模型 id 字符串匹配registry.ts:365-366), 新模型命名一变就可能选错工具。
  • 沙箱网络的 allowedHosts 只在 proxy 模式可用:非 proxy 模式带 allowedHosts 会直接 unavailable 拒绝 (packages/kilo-sandbox/src/network.ts:75-77,报错文案在 :35);proxy 模式由 networkEnvironment:59)注入代理环境变量。

12. 横向对比(一句话)

Kilo CLI 是 opencode 的下游 fork —— README 的 FAQ 直说了这件事(README.md:171), 主包目录就叫 packages/opencode、内部依赖 @opencode-ai/core,源码里满地的 // kilocode_change 标记就是 diff 边界。工具契约、registry 结构、Permission.evaluate 基本沿用上游(对照 opencode 的工具系统opencode 的权限模型);Kilo 自己加的是权限的硬化层hardRuleset veto、ConfigProtectionReadPermission.hardenreadOnlyBash 白名单)和沙箱网络策略。 换句话说:上游给了一套可用的权限模型,Kilo 往上补了一圈「用户点过总是允许之后也不该放行」的场景。


13. 代码地图(导航索引)

主题文件路径关键符号
工具契约四件套packages/opencode/src/tool/tool.tsDef / Context / ExecuteResult / InvalidArgumentsError
工具包装(解码 + 截断 + span)packages/opencode/src/tool/tool.tswrap / define / init
内置工具清单与门控packages/opencode/src/tool/registry.tslayer 内的 builtin 数组
自定义 / plugin 工具接入packages/opencode/src/tool/registry.tsfromPlugin / isPluginTool / zodJsonSchema
每轮筛选 + 动态描述packages/opencode/src/tool/registry.tstools / describeTask / describeSkill / webSearchEnabled
Kilo 追加工具packages/opencode/src/kilocode/tool/registry.tsKiloToolRegistry.extra / build / describe
每轮桥接给模型packages/opencode/src/session/tools.tsresolve / context
工具 JSON Schema 规整packages/opencode/src/tool/json-schema.tsfromTool / fromSchema / normalize
按模型改写 schemapackages/opencode/src/provider/transform.tsschema(Moonshot / Gemini 分支)
权限数据模型与求解packages/opencode/src/permission/index.tsRule / Ruleset / resolve / ask / reply / Reply
基础求值与工具禁用packages/core/src/permission.tsevaluate / merge / disabled / EDIT_TOOLS
通配匹配packages/core/src/util/wildcard.tsmatch
命令归约表packages/opencode/src/permission/arity.tsprefix / ARITY
shell 的两次 askpackages/opencode/src/tool/shell.tsask / collect / Scan
外部目录鉴权助手packages/opencode/src/tool/external-directory.tsassertExternalDirectoryEffect
agent 画像与默认规则packages/opencode/src/agent/agent.tsInfo / layer / baseDefaults
Kilo 的 agent 补丁packages/opencode/src/kilocode/agent/index.tsreadOnlyBash / prepare / patchAgents / hardenSystemAgents / getMcpRules
plan/ask 硬化packages/opencode/src/kilocode/session/prompt.tsguardPermissions / hardPermissions / askPermission
静态裁剪工具列表packages/opencode/src/session/llm/request.tsresolveTools
subagent 权限派生packages/opencode/src/agent/subagent-permissions.tsderiveSubagentSessionPermission
配置文件保护packages/opencode/src/kilocode/permission/config-paths.tsConfigProtection.isRequest / DISABLE_ALWAYS_KEY
.env 读权限硬化packages/opencode/src/kilocode/permission/read.tsReadPermission.harden
plan_exit 工具packages/opencode/src/kilocode/tool/plan.tsPlanExitTool
skill 加载工具packages/opencode/src/tool/skill.tsSkillTool
skill 服务与可见性packages/opencode/src/skill/index.tsavailable / require / fmt
skill 远程发现packages/opencode/src/skill/discovery.tspull / download
MCP 客户端packages/opencode/src/mcp/index.tstools / convertMcpTool / Interface
MCP OAuthpackages/opencode/src/mcp/oauth-provider.tsmcp/auth.tsMcpOAuthProvider / Tokens / Entry
plugin 加载packages/opencode/src/plugin/index.tsplugin/loader.tslayer / internalPlugins / loadExternal
沙箱策略packages/opencode/src/kilocode/sandbox/policy.tsprofile / executeTool / executeMcp / secure
沙箱网络判定packages/opencode/src/kilocode/sandbox/network.tsnetwork-tools.tsbuiltin / remote / tool / mcp / opaque
doom_loop 保险丝packages/opencode/src/session/processor.tsDOOM_LOOP_THRESHOLD