安全分层:Trust、Permission、Sandbox、Network
你以为安全就是"sandbox把进程关起来"。实际上Harness的安全是四层独立机制的乘积——Trust静态信任、Permission运行时审批、Sandbox操作系统隔离、Network出站管控——它们互不假设、独立失败、组合生效。本章把这四层分别掀开,按代码里的生效点把机制讲清楚。
把安全理解成“在进程外面套一层 sandbox”,很容易漏掉几个真实故障点:runner 启动失败后代码是否还会继续执行,0day 逃逸后还有没有第二道拦截,权限检查如果只写在 listener 里会不会被 prepend: true 绕过,出站请求有没有机会把内部 credential 带到外部服务。
单一围墙思维的问题不在于围墙不结实,而在于它只给系统一次机会。围墙一破,后面的机制就没了。
Harness 的安全设计是 defense in depth:四层独立的机制,每层都不假设其他层是完美的。它们不是串联的——串联意味着一层失败后面全完。它们是乘积:每层独立判断,任何一层的拒绝都是最终拒绝。
Trust × Permission × Sandbox × Network = 实际安全面
每个 × 都是独立的”全失效”语义:Trust 拒绝了,即使 Permission/Sandbox/Network 全通过也无用。反过来也一样。
下面我们把这四层各自掀开看实现:它们在代码里分别做了什么、在哪里生效,以及怎么做到“隔壁层出事了,自己也不会被拖下水”。
第一层:Trust——静态信任边界
Trust 是最底层的分类决策:什么代码被允许进入 Harness 同进程的可信域?
这不是一个运行时概念。Trust 在部署时已经确定:
- 系统级 plugin(
@deepseek-ai/dsh-*):直接注册为 cordis 服务,拥有ctx的全部能力。它们是 Harness 自身的一部分。 - 配置级 plugin(用户在 settings 中启用的):通过 Loader 进入 cordis 树,受 fiber 生命周期管理,但仍是同进程代码。
- 模型输出:不可信数据。模型生成的任何内容——工具参数、justification 文本——都被当作 untrusted input 处理。
Trust 层的关键设计原则是:绝不让不可信数据直接变成可信操作。模型输出不能直接注册 service、不能写入 plugin config、不能修改 approval policy。它只能走已定义好的工具调用路径,而这条路径上有 Permission 层在等着。
注意这里的关键缺席:没有 'always' 或 'auto-approve' 策略。这是 Trust 层做出的静态架构决策——在代码结构层面,不存在一条路径能让所有操作自动通过。即使部署者手动配置 policy: 'ask',仍然需要一个 composed answerer 来实际回答 'allowed-once'。
Trust 层还通过 credential redaction(redactSecrets)确保 role('secret') 字段永远不会跨越 wire boundary。秘密在离开同进程边界前被结构化移除——不是靠运行时检查”这个值看起来像密码”,而是靠 schema 声明的静态位置。
第二层:Permission——运行时审批门
Permission 层处理的问题是:即使某个操作来自可信代码路径,在运行时是否应该执行?
核心实现是 ApprovalService。它的 request() 方法是所有需要人类批准的操作必经的唯一入口。
这里的关键设计是:'never' 策略的强制不在 listener 层。如果它是作为一个事件 listener 实现的,任何人都可以通过 prepend: true 注册一个更早的 listener 来拦截请求。但 ApprovalService 在 decide() 方法内部——在进入 waterfall dispatch 之前——就检查策略。这是服务层的结构性保证,不靠 listener 排序碰运气。
权限的一次性语义
Permission 层的批准是 'allowed-once'——一次性的。不存在”记住这个决定”或”本会话内自动通过”的机制。每一次需要批准的操作都是独立的一次询问。
// escalation.ts — 批准结果直接映射
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected...`)
case 'cancelled': throw new Error(`approval...was cancelled`)
case 'unavailable': throw new Error(`...no approval channel is available`)
注意 'unavailable' 的处理:如果没有 answerer 被 compose 进来,请求不是被忽略,而是被拒绝。这就是 fail-closed——缺少批准能力本身就是拒绝。
Escalation 的严格 widening
Sandbox escalation(从 read-only 提升到 workspace-write 或 danger-full-access)必须经过 Permission 层。但不是任意提升——必须是严格 widening:
这张表是在执行时检查的,不是在 schema 层面:
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider...`)
}
这个检查发生在 approveEscalation 内部,在人类被询问之前。一个不满足严格 widening 的请求甚至不会触发审批 prompt——它直接被拒绝。非 widening 请求永远不会打扰人类。
审计的完备性
每一次 approval 请求都产生一对审计事件:approval/asked 和 approval/decided。这对事件必须在一个 open turn 内——hasOpenTurn 检查保证了这一点。如果在 turn 之外请求审批,直接抛错。这不是为了美观,而是为了 replay 的正确性:turn 边界是持久化日志的 commit 边界,turn 外的事件在 crash recovery 时会被视为垃圾数据丢弃。
第三层:Sandbox——操作系统级隔离
Permission 层解决了”是否应该执行”,Sandbox 层解决了”执行时能碰什么”。
Sandbox 层的核心抽象是 SandboxProvider——一个 abstract service,它的唯一方法是 confine(argv, policy):接受一个要执行的命令和一个文件策略,返回一个被包装过的 argv。
三种模式的精确语义
type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
read-only:只允许/dev/null等必须的 sink。连/tmp都不可写。workspace-write:工作区目录 + 平台 temp 区域可写,其余只读。danger-full-access:绕过隔离——名字里有danger不是修辞,是提醒。
注意 danger-full-access 不进入 confine() 路径——它直接运行原始 argv。Sandbox 层只管 confined 模式(ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>)。
平台 runner chain 的选择机制
LocalSandboxProvider 实现了具体的平台隔离。它的核心是一个按平台划分的 runner chain:
选择逻辑是:
- 单一候选者(macOS、Windows):直接选中,不做 probe。如果执行时 runner 本身拒绝了,fail-closed 由 runner 自己的 stderr signature 触发。
- 多候选者(Linux):按 chain 顺序做 functional probe,第一个通过的胜出。两个都失败 →
'unavailable'→SandboxUnavailableError。
Functional probe 不是”检查二进制是否存在”——它是真正运行一次受限命令来验证 kernel 接受 profile:
// bwrap probe:真正创建一个 read-only bind mount 然后执行 true
spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev',
'--proc', '/proc', '--die-with-parent', '--', 'true'])
probe 通过 = exit 0,probe 失败 = 这个 runner 不可用,尝试下一个。
每个 runner 的 denial dialect
不同 runner 在文件访问被拒绝时产生不同的 stderr 文本:
const DENIAL_SIGNATURES = {
bwrap: ['read-only file system'], // EROFS
landlock: ['permission denied'], // EACCES
seatbelt: ['operation not permitted'], // EPERM
'windows-acl': ['access is denied', 'access to the path', 'permission denied'],
}
consumer 匹配的是当前选中 backend 的 signatures,不是全部 backend 的联合。如果你匹配联合,你会声称发生了某个 backend 永远不会产生的 denial——这是错误分类。
Fail-closed 是不可绕过的
当 platform chain 为空或所有 probe 失败时,SandboxUnavailableError 被抛出。这不是 warning、不是 log、不是 fallback——命令不执行。错误码 SANDBOX_UNAVAILABLE 通过 HarnessError 传播到 tool/result,让 caller 明确知道”不是命令失败了,是隔离环境不可用”。
Writable roots 的统一派生
workspace-write 模式下哪些路径可写?答案来自一个共享函数 writableRoots(policy):
export function writableRoots(policy: SandboxExecutionPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
}
Seatbelt profile 和 in-process filesystem fence 都从这个函数派生 allow-list。两个 enforcement dialect 共享同一个 truth——“write tool 不能写 /tmp 但 bash 可以”的不对称永远不会出现。
bwrap 和 Landlock 保持自己的 grant spelling(ephemeral /tmp mount、launcher-owned flags),但 parity 被测试锁定。
策略的 per-call 而非 per-provider 粒度
一个关键设计:SandboxPolicy 是 per-call 携带的,不是 provider 上的固定配置:
Two consumers may confine under different policies at the same instant (bash under
read-onlywhile a confined child agent needs its state directory writable), and an approved escalated retry is a new call with a wider policy.
这意味着同一个 provider 实例在同一毫秒可以服务两个不同策略的 confine 请求。策略是调用者传入的参数,不是 provider 的状态。
第四层:Network——出站连接管控
Network 层管控的是:当代码需要发出网络请求时,哪些目标和行为是被允许的?
注意 SandboxMode 的注释明确说:
Network and process visibility are outside this vocabulary.
Sandbox 管文件,Network 管网络——两层是独立的。sandbox 把你的文件权限限死了,但如果你有网络能力,仍然可能向外部泄露信息。所以 Network 层是独立的防御。
URL 卫生检查
HttpFetchProvider 在发出任何网络请求之前,通过 validateFetchUrl 执行一组结构化验证:
三条规则:
- 只允许 http/https——不能
file://、不能ftp://、不能自定义 scheme。 - 拒绝 embedded credentials——
http://user:pass@host/被直接拒绝(WEB_BLOCKED_URL)。这防止模型在 URL 中泄露 credential。 - 长度上限——防止通过超长 URL 进行 buffer 相关攻击或 DoS。
Credential 剥离的结构性保证
Provider 文档级注释明确说:
Requests carry no browser cookies or ambient credentials.
这是设计层面的保证——HttpFetchProvider 不在 fetch() 调用中附加任何 cookie jar 或 ambient credential。每个请求都是 anonymous public fetch。如果你需要认证访问,那需要走一条完全不同的、有明确 Trust/Permission 约束的路径。
Same-origin redirect 约束
redirect 只跟随 same-origin。如果服务器返回 302 到另一个 origin,provider 不自动跟随——它告诉 agent “去对那个 URL 发起一次新的请求”。为什么?因为每次新的 tool call 都经过 Permission 层。cross-origin redirect 如果被自动跟随,就绕过了 Permission 层对新目标的审查。
这是四层组合的一个具体例子:Network 层的约束(拒绝 cross-origin redirect)确保 Permission 层(每次工具调用的审批)不被绕过。
资源上限
interface HttpFetchLimits {
maxUrlLength: number // URL 长度上限
maxResponseBytes: number // 响应体字节上限
maxBodyChars: number // 解码后字符上限
timeoutMs: number // 超时
maxRedirects: number // redirect 跳数上限
}
每一个上限都是一道独立的 fail-closed 检查。超过任何一个,请求被拒绝或截断——不是”尽力而为地继续”。
四层的乘积组合
现在你理解了每一层,让我们看它们如何组合。
考虑一个场景:agent 想执行 rm -rf /important。
-
Trust 层:模型输出 → 工具调用参数。参数被当作 untrusted input,只能通过已注册工具的 schema 验证路径进入执行。
rm -rf /important需要通过 bash tool 的参数解析。✓ 通过(Trust 层不阻止这个——这是合法的工具调用格式)。 -
Permission 层:bash tool 在 confined 模式下执行。如果当前 sandbox mode 是
read-only,执行继续但 sandbox 会阻止写操作。如果 agent 请求 escalation 到danger-full-access,approveEscalation会向人类展示 justification。人类拒绝 →'rejected'→ 命令不执行。✓ 如果人类批准,继续到下一层。 -
Sandbox 层:即使 Permission 批准了 escalation,如果模式仍然是
workspace-write,rm -rf /important(假设/important不在 workspace 内)会被 bwrap 的 read-only bind mount 拒绝。stderr 输出read-only file system。命令执行了但写操作被 kernel 拒绝。 -
Network 层:如果命令试图通过 curl 向外部发送数据,Network 层的约束(如果有对应的出站 policy)会拦截。即使 sandbox 限制了文件,网络仍需独立管控。
关键观察:任何单层的失败都不导致全面突破。
- Trust 层不做此类阻止?Permission 层拦。
- Permission 层人类批准了?Sandbox 层仍然限制文件路径。
- Sandbox 被 0day 绕过?Network 层仍然阻止数据外泄。
- Network 层配置错误?Sandbox 的文件限制仍然保护本地数据。
Sandbox Policy 的 session-aware 解析
Sandbox 策略不是全局的——它是 per-session 的,通过 SandboxPolicyService 管理:
优先级链是:
显式批准的 mode(escalation grant)
> session 的 sandbox/mode 事件 fold
> deployment 配置默认值
每一次 capability 调用都走一遍 resolve()——不缓存、不记忆上一次结果。这保证了 session 中途切换 mode(通过 setSandboxMode)立即对下一次调用生效。
session 的 mode override 是怎么持久化的?通过 session event log:
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'sandbox/mode') return event.data.mode
}
return undefined
}
从最新事件向前扫描,找到最后一个 sandbox/mode 事件。没有找到 = 使用 deployment 默认值。这是纯函数 fold——replay 不需要特殊的 catch-up 机制,因为重放日志就是状态。
Windows ACL:部分 enforcement 的诚实承认
三个平台的 sandbox 并非同等强度。Linux 的 bwrap 和 macOS 的 Seatbelt 声称 full enforcement——它们从构造上覆盖了所有承诺的文件效果。但 Windows 的 ACL restricted-token runner 只声称 partial:
const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
bwrap: 'full',
landlock: 'full',
seatbelt: 'full',
'windows-acl': 'partial',
}
为什么?源码注释解释了两个原因:
WRITE_RESTRICTED必须在 restricting list 中保留Everyone:Windows 进程初始化需要它。这意味着任何对 Everyone 授予写权限的外部对象仍然可写。- NTFS 硬链接别名:一个在工作区内的文件可以被硬链接到工作区外的路径。ACL 基于对象而非路径,所以工作区 SID 的写权限通过硬链接”逃逸”到外部路径。
这里的设计哲学是:不撒谎。如果你的 enforcement 有已知局限,就声称 partial 而不是假装 full。caller 需要绝对边界时,看到 partial 就知道不能完全依赖它——需要其他层(Permission、Network)来补偿。
这就是四层乘积的价值:Windows 上 Sandbox 层是 partial,但 Permission 层的 escalation approval + Network 层的出站限制仍然完整。partial sandbox 不是安全漏洞——它是”这一层能做的最好程度”,其余由其他层补。
模型可见的策略上下文
Sandbox 策略不只在内核层面生效——它被渲染为模型可读的 system prompt 上下文:
function renderPolicyContext(policy: SandboxExecutionPolicy): string {
switch (policy.mode) {
case 'read-only':
return 'Current DSH file policy: read-only. Any available operation...'
case 'workspace-write':
return `Current DSH file policy: workspace-write...${JSON.stringify(policy.workspaceRoot)}`
case 'danger-full-access':
return 'Current DSH file policy: danger-full-access...'
}
}
这段文字被注入为 sandbox:policy 上下文,order 110——在 persona 之后、工具描述之前。模型看到当前策略后,可以:
- 在
read-only下不尝试写操作(避免浪费一次 turn) - 在
workspace-write下知道只有workspaceRoot内的路径可写 - 知道 escalation 是可选项(如果 escalation 字段被挂载的话)
但注意渲染函数的措辞:“Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.”——模型被告知不要自行拒绝,而是尝试执行、让系统来拒绝。这是因为模型对 “什么路径在 workspace 内” 的判断不可靠——让 kernel 做裁决,然后根据 denial 结果决定是否 escalate。
每层的独立失效边界
最后让我们明确每一层的独立失效假设:
| 层 | 保护什么 | 独立失效假设 |
|---|---|---|
| Trust | 阻止不可信代码进入同进程 | 即使 plugin 被恶意替换,Permission 层仍独立工作 |
| Permission | 阻止未批准的操作执行 | 即使人类错误批准,Sandbox 层仍限制文件访问 |
| Sandbox | 限制进程能碰的文件路径 | 即使 sandbox 被逃逸,Network 层仍阻止数据外泄 |
| Network | 限制出站连接的目标和行为 | 即使网络被突破,Sandbox 的文件限制仍保护本地 |
每一层的设计都不包含”如果其他层正常工作则可以放松”的逻辑。它们是真正独立的——不是”如果 sandbox 启用了就跳过 permission 检查”,而是两层都做自己的检查,结果的交集才是最终的允许范围。
一个容易忽略的细节
ApprovalService 的 'never' 策略为什么在 decide() 内部强制,而不是作为一个 waterfall listener?
源码注释给出了明确答案:
The ‘never’ policy is decided HERE, before any dispatch: a listener registered with
prepend: trueafter this service mounts would sit ahead of any gate LISTENER, so a listener-shaped gate cannot keep the documented promise that ‘never’ rejects deterministically regardless of registration order — only the service’s own request path can.
如果策略检查是 listener,cordis 的 listener 注册顺序就变成了安全边界——任何能调用 ctx.on('approval/request', ..., { prepend: true }) 的代码都能坐在策略检查前面,看到请求、甚至返回 'allowed-once'。
这就是为什么 Permission 层的策略门是服务方法内部的代码分支,不是事件分发链上的一个节点。结构性保证 vs 注册顺序的偶然保证——前者不可能被同进程的合法代码绕过。
你的验证实验
-
验证
'never'策略的不可绕过性:在一个policy: 'never'的 session 中,用prepend: true注册一个总是返回'allowed-once'的 listener。观察ApprovalService.request()是否仍然返回'rejected'。它应该返回'rejected'——因为策略检查在 dispatch 之前。 -
触发
SandboxUnavailableError:在一个platform: 'freebsd'(不在 PLATFORM_CHAINS 中)的 internals 配置下调用confine()。观察抛出的错误码是SANDBOX_UNAVAILABLE,而不是 fallback 到 unconfined 执行。 -
观察 denial signatures 的 per-backend 隔离:在 bwrap 下写一个 read-only 路径,观察 stderr 包含
read-only file system。在 Landlock 下做同样的事,观察 stderr 包含permission denied。在 Seatbelt 下,是operation not permitted。三个 backend 永远不会产生彼此的 signature。 -
验证 cross-origin redirect 拒绝:通过
HttpFetchProvider请求一个返回 302 到不同 origin 的 URL。观察结果是WEB_REDIRECT_BLOCKED错误而非自动跟随。
你现在应该相信的事情
安全不是一道墙,而是一个乘积。四层独立机制的组合意味着攻击者需要同时突破所有四层才能实现完全控制。任何单层的 0day、配置错误、或设计缺陷都不能独自导致全面突破——因为其他三层不知道也不关心那一层发生了什么,它们各自做自己的检查。
这就是 defense in depth 在工程层面的意思:不是”多加几层 wrapper”的形式主义,而是每一层都有独立的 fail-closed 语义、独立的拒绝路径、独立的审计证据。四个独立的”不”,必须全部变成”是”,操作才能执行。
下次你看到有人说”我们有 sandbox 所以安全”,你可以追问:
- Trust 层怎么保证不可信代码进不来?
- Permission 层的策略门在哪里强制?是 service 路径还是 listener 排序?
- Sandbox 的 enforcement 是 full 还是 partial?
partial的残余风险谁来补偿? - Network 层独立于 sandbox 吗?如果 sandbox 被逃逸,出站请求受什么约束?
如果任何一层的答案是”依赖另一层”——那它就不是真正的 defense in depth。它只是一道被画成四层的单一围墙。