dsh 源码解析
第三段 · 能力与治理 · 第 09 章

沙箱与权限审批:模式、审批、同轮升级

三个值的闭合词汇、fail-closed 审批、被拒后唯一被认可的重试

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/sandbox/(4 包)、packages/interaction/user-approval/packages/interaction/permission-presets/packages/shell/tool-bash/

第 8 章讲了 ctx.sandbox 作为一个 seam 怎么被替换。这一章讲这套权限机制作为一个整体是怎么运转的:模型看到什么、被拒绝时能做什么、人在哪里被问、以及这些事实怎么在会话日志里留下痕迹。

这套机制横跨五个包,但它回答的是三个问题:

  1. 这次调用允许改哪些文件?ctx.sandboxPolicy + ctx.sandbox
  2. 需要人同意的时候,怎么问、问不到怎么办?ctx.approval
  3. 被拒之后模型能不能自救?approveEscalation 的同轮升级)

全章图示:assets/ch09-sandbox-approval.svg

沙箱模式、审批 seam 与同轮升级


1. 三个值的闭合词汇

export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'

packages/sandbox/sandbox/src/index.ts:29 的注释把范围钉死:

File-effect policy for confined processes. read-only permits only required sinks such as /dev/null; workspace-write also permits the workspace and a backend-defined temp area; danger-full-access bypasses confinement. Network and process visibility are outside this vocabulary.

三个值,只管文件效果。网络不管,进程可见性不管(后者是后端特定的,由 sandbox-local 自己记录)。

这个限制是刻意的,而且在多处被重申。sandbox-policy 的 README 在"已知限制"里再说一次:

File-effect modes onlySandboxMode governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.

一个安全词汇最重要的属性是它的边界被写清楚了。 三个值的枚举很容易让人以为"read-only 就是安全"。写明"网络不受限",读者才能自己判断这够不够。


2. 策略:谁解析、什么时候解析、解析出什么

2.1 三层优先级,一次调用一份

SandboxPolicyService.resolve()packages/sandbox/sandbox-policy/src/index.ts:135)只有五行:

resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
  const { session } = request
  return {
    mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
    workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
    ...session === undefined ? {} : { sessionId: session.id },
  }
}

三层优先级:批准的显式模式 > 会话的最后一次 sandbox/mode 事件 > 部署默认值

根目录的来源不同:会话头里不可变的 cwd,只有无会话的调用才落到配置的 fallback。所以"工作区在哪"不需要另一个事件——它在第 5 章那个不可变的 SessionHeader 里。

默认值是 read-only,Config 的注释写明理由:mode: 'read-only' is the fail-safe default; a deployment that wants a workspace-writable agent opts in explicitly。

2.2 workspaceRoot 为什么要先 canonical 再 resolve

function resolveWorkspaceRoot(path: string): string {
  return resolvePath(canonicalPath(path))
}

注释:Resolve filesystem identity before lexical normalization can erase symlink-sensitive components.

canonicalPath()sandbox/src/roots.ts:30)用的是 realpathSync.native 而不是 realpathSync

Node's JavaScript realpath implementation lexically collapses .. before resolving a preceding symlink on some platforms. The native implementation follows the filesystem's component-by-component lookup, matching chdir/spawn and the enforcement layers this identity feeds.

顺序错了就是漏洞。 先做词法归一化(把 a/symlink/../b 折成 a/b),再解析符号链接,得到的路径可能和内核实际访问的路径不同——而围栏检查的是前者,syscall 走的是后者。

失败时的行为也写明了:

the canonical path, or the spelling as-is when resolution fails (a missing root matches nothing until it exists — the conservative outcome; inventing a fallback would grant a path the caller never named).

解析失败就用原样。 不存在的根匹配不到任何东西,这是保守结果;编一个 fallback 反而会授权一个调用方从没说过的路径。

2.3 writableRoots():一个函数,两个执行家族

export function writableRoots(policy: SandboxExecutionPolicy): string[] {
  if (policy.mode !== 'workspace-write') return []
  return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
}

模块注释解释了它为什么必须只有一份:

The Seatbelt profile (dsh-sandbox-local) and the in-process filesystem fence (dsh-fs-sandbox) both derive their allow-list here, so "the write tool cannot write /tmp but bash can" asymmetries cannot arise between them.

os.tmpdir() 单独被列进去也有理由:the real temp area for mkstemp-family tools; omitting it would deny what the mode promises

而 bwrap 和 Landlock 保留自己的写法(临时 /tmp 挂载、launcher 自己的 flag)——注释称之为 the honest per-runner differences recorded in the sandbox RFC — with parity pinned by test

该统一的统一(模式含义),该承认差异的承认(内核方言),差异用测试锁死。 这比强行让所有后端共用一段代码更诚实。

2.4 模式切换就是一个事件

session-mode.ts:70

session.append('sandbox/mode', { mode })

注释:the switch IS its event; nothing mutates mode state out of band

README 给出完整公式:

effective = explicit grant ?? fold(events) ?? deployment default,so an override survives restart by replay and two sessions never see each other's state.

这就是第 5 章那条不变式在权限层的应用:没有内存里的"当前模式"变量。重启后 replay 日志得到同一个模式,两个会话天然隔离。

还有一个可选的 ./invariant 伴生插件,拒绝伪造的、值不在闭合词汇里的 sandbox/mode 事件。


3. 模型看到的策略:一段能力中立的散文

renderPolicyContext()sandbox-policy/src/index.ts:38)就是三个 switch 分支,但每一句都值得读。

read-only

Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.

三个细节:

"Any available operation enforced by the DSH file sandbox",而不是"write 和 edit 工具"。 函数上方的注释就叫 Render the policy without claiming which capabilities are mounted。策略拥有者不知道装了哪些工具——列举工具名就是撒谎的开始。

"Do not refuse a required modification from this policy alone"。 这是对一个真实失效模式的补丁:模型看到 read-only 就在聊天里说"我没权限,请你手动改",而实际上试一下工具会得到一个带升级提示的拒绝。告诉模型策略,同时告诉它别把策略当成放弃的理由。

"the standing mode"(常驻模式)。 措辞留了余地,因为一次批准的升级会让这次调用跑在更宽的模式下。

workspace-write 只写会话工作区的规范路径,然后一句 Some platform temporary areas may also be writable。README 解释为什么不列举:

Temporary areas are deliberately summarized — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the current context.

枚举不出真话就别枚举。 临时区是策略解析之后由后端选的,此刻还不知道。

3.1 KV cache 的考虑

这段策略是 systemPrompt.context()(第 6 章的 context,不是 section),order: 110。README 写明后果:

The stable system prompt remains byte-identical across mode changes. A changed full context snapshot is appended after retained history, preserving the prior cached prefix; subsequent unchanged requests reuse that retained snapshot.

模式切换不动 system prompt 一个字节。新的 runtime context 快照追加在保留历史之后,前缀缓存全部有效。这正是第 6 章讲的"为什么 runtime context 是 context 而不是 section"的实际收益,在这里体现为"用户切一次权限不会让整个 prompt 缓存失效"。


4. 审批 seam:fail-closed 的四种理由

4.1 四个结果,一个是授权

export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'

sandbox/src/escalation.ts:93;审批 seam 自己的 ApprovalOutcome 与它结构相同,这样 ApprovalService.request 的返回值可以直接赋值,而 sandbox 包不需要 import 审批包。)

allowed-once 是唯一的授权,而且名字就说明了它的范围。README 的"已知限制"直说:

Only one-shot grants exist — the outcome vocabulary has allowed-once but no allow-always, remembered rule, revocation, or grant store; session policy is only ask / never.

没有"总是允许"。 没有记住的规则、没有撤销、没有授权存储。这是一个很大的产品取舍:用起来更烦,但也意味着"这次批准"永远不会变成"以后都行",而且不需要维护一个授权存储的生命周期和撤销语义。

4.2 never 在 dispatch 之前判定

decide()user-approval/src/index.ts:304)里这段注释是整章最值得抄走的一条:

// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after 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.
if (this.effectivePolicy(session) === 'never') return 'rejected'

never 策略不能实现成一个监听器。因为后挂载的插件可以用 prepend: true 插到它前面,于是"never 一定拒绝"这个承诺就变成了"取决于注册顺序"。

一个必须确定成立的策略,必须在服务自己的代码路径里判定,不能实现成扩展点上的一个参与者。 这和第 7 章那条"waterfall 之后再加一层单调 guard"是同一类判断的两种形态。

4.3 三种失败都归 unavailable

同一个函数里:

const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
  () => this.ctx.waterfall(
    scopeTarget(this, req.agent), 'approval/request', req,
    () => Promise.resolve<ApprovalOutcome>('unavailable'),
  ),
).then(
  outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
  () => 'unavailable',
)

三条 fail-closed 路径:

  • 没有 answerer → waterfall 的默认返回 unavailable
  • answerer 返回了词汇外的值 → 归一化成 unavailable(注释:instead of leaking it into callers' closed-union switches);
  • answerer 抛错 → 也是 unavailable(注释:must fail the QUESTION closed, not the caller's tool call open — the seam contains its callbacks)。

还有两个细节:

Promise.resolve().then(() => waterfall(...)) 而不是直接调用,注释解释了:a listener that throws SYNCHRONOUSLY (before its first await) must land in the same rejection path as an async one — Promise.resolve(call()) would let it escape the containment into the caller.

同步抛错和异步抛错必须走同一条路。 这是个很容易漏的细节:Promise.resolve(f())f() 的同步异常会直接穿透。

取消是竞速而不是检查:请求 signal 和 answer promise 赛跑,signal 先到就 cancelled。注释指出 late answer 被 discarded by construction——已 settle 的 promise 再 resolve 是 no-op。

4.4 审批必须在一个打开的 turn 里

request()user-approval/src/index.ts:257)第一件事:

if (!hasOpenTurn(session.events)) {
  throw new Error(
    'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
    + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
    + 'Ask from inside the turn that needs the decision.',
  )
}

turn 之间的裸事件在重载时是崩溃尾部垃圾。 第 5 章的日志修复逻辑靠 turn 边界判断哪些事件是崩溃残留;一对不在 turn 里的审批事件会被当成垃圾丢掉,那审计记录就丢了。

所以:

const id = ApprovalRequestId(randomUUID())
session.append('approval/asked', { id, toolName: req.toolName, … })
const outcome = await this.decide(req, session)
session.append('approval/decided', { id, outcome })
return outcome

成对的审计记录,中间夹着人的决策时间。 JSDoc 补了一条:A failure that prevents either audit append from committing still rejects because returning an unlogged decision would violate the pair.

没记上就不算决定。 宁可让工具调用失败,也不返回一个没有审计记录的批准。

这两个事件都是 log-only:模型只看到工具最终的结果,看不到 approval/asked。人机审批 UI 不是上下文。

4.5 answerer 是监听器,但只能有一个终端 answerer

Answerers are approval/request waterfall listeners. Return an outcome to answer for an owned agent or call next() to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism.

最后半句是纪律:兄弟监听器的顺序不是策略优先级机制。 挂两个 answerer 然后依赖谁先注册,是在用一个不保证的东西当规则用。

而 agent-scoped 过滤(第 6 章那套 scope)在这里的用处很实际:ACP 自动化桥只回答它自己拥有的会话的请求,不会替别人的 agent 做决定。

4.6 策略切换会告诉模型

setPolicy()user-approval/src/index.ts:226):

setApprovalPolicy(agent.session, policy)
agent.inject(createUserMessage({
  content: [{ type: 'text', text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).` }],
  source: { kind: 'plugin', plugin: 'user-approval' },
}))

用的是第 4 章的 inject(排进最近 step,不唤醒)。切换权限不该唤醒一个空闲的 agent,但下次它跑起来时必须知道这件事变了。而且 source 标了是插件写的,不是用户打的字。

never 策略给模型的那句话很直接:

Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).

不只说"会被拒",还直接说"别设那个参数"。 因为一次注定失败的升级尝试要花掉一轮工具调用。


5. 同轮升级:唯一被认可的"被拒后重试"

这是整章最精巧的部分。packages/sandbox/sandbox/src/escalation.ts 的模块注释先交代了它为什么独立成一个模块:

The escalation vocabulary and choreography shared by every sandbox-enforcing tool family (dsh-tool-bash, dsh-tool-fs): the strictly-wider ladder, the argument-pairing validation, the model-facing denial/hint markers, and approveEscalationOne home keeps the two families' approval ordering and verbatim error texts from drifting apart.

两个工具家族共享逐字相同的错误文本相同的审批顺序

而它不依赖审批包和 agent 包:

The channel is a minimal STRUCTURAL function shape (EscalationAsk), not the approval service type: the tool layer — which owns the agent, the call id, and the tool name — closes over ctx.approval.request(...) and hands the closure down.

结构化类型代替包依赖。 EscalationApprover<A, C> 对 agent 类型和 call-id 类型泛型,工具层把 A/C 推断成自己的 Agent/CallId。这样 sandbox 包不 import approval 包、不 import agent 包,却能通过 ctx.approval 解析升级。

5.1 严格加宽:表在这里,检查在执行时

export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
  'read-only': ['workspace-write', 'danger-full-access'],
  'workspace-write': ['danger-full-access'],
}

注释:Checked at EXECUTION, never baked into a tool schema — the schema's enum is ESCALATION_TARGETS, because schemas are registry-global while the effective mode is per-call truth.

export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']

为什么 schema 里的 enum 是完整的两个目标,而不是"比当前默认模式更宽的那些":

Advertised whenever the mounted capability confines: cutting the enum down to the modes wider than the composition's DEFAULT would strand a session whose effective mode sits below it (a danger-full-access default would advertise nothing while a narrower-switched session stays confined with no lever).

schema 是注册表级的,有效模式是按调用的真相。 部署默认 danger-full-access 时若按默认裁剪 enum,那 schema 里就一个选项都没有了——而一个手动切窄到 read-only 的会话就再也没有升级的杠杆了。

所以:schema 给完整的闭合目标词汇,严格加宽在执行时按这次调用的有效模式检查。

5.2 参数配对:schema 表达不了的规则

export function validateEscalationArgs(sandboxPermissions, justification): void {
  if (sandboxPermissions !== undefined && justification === undefined) {
    throw new Error('invalid escalation: sandbox_permissions requires a justification')
  }
  if (justification !== undefined && sandboxPermissions === undefined) {
    throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
  }
  if (justification !== undefined && justification.trim().length === 0) {
    throw new Error('invalid justification: expected a non-empty sentence')
  }
}

注释:an approval prompt without a reason, or a reason driving nothing, is a malformed ask

两个方向都查。只给理由不给模式也是错的——那个理由驱动不了任何东西,说明模型对自己在干什么有误解。

5.3 有序的 fail-closed 序列

approveEscalation()escalation.ts:157)的顺序不能变:

if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
  throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
}
if (approval.approver === undefined) {
  throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
}
if (approval.agent === undefined) {
  throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
}
const outcome = await approval.approver.request({
  agent: approval.agent, toolName: approval.toolName, callId: approval.callId,
  reason: `escalate sandbox to ${mode}: ${justification}`,

})
switch (outcome) {
  case 'allowed-once': return mode as SandboxMode
  case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
  case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
  case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
  default: return assertNever(outcome, 'EscalationOutcome')
}

四点:

先查加宽,再找通道。 JSDoc 明确:A non-widening request never prompts a human. 模型请求一个不更宽的模式(比如已经是 workspace-write 还要 workspace-write)是它自己的错误,不该拿去烦人。

BEFORE anything executes。 抛出即工具调用的 isError(第 7 章的 prepare 阶段),什么都没跑。这就是为什么升级必须在执行前解析:一个"先跑再问"的设计根本没法回滚。

审批理由自包含。 escalate sandbox to ${mode}: ${justification} 存进 approval/asked,注释说明 the target mode is part of the grant's identity。审计记录里能看到"批的是升到哪个模式、模型给的理由是什么"。

每条路一段不同的逐字文本。 第 7 章讲过这个原则,这里是第二次出现:人拒绝了 → 换做法;通道没配 → 换做法也没用。合成一条会抹掉这个区别。

5.4 两个模型可见的标记

export function sandboxDenialMarker(mode: SandboxMode): string {
  return `[sandbox: file access denied under ${mode} mode]`
}

export function escalationHintMarker(subject: string): string {
  return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
}

拒绝标记的注释:the one vocabulary both enforcing families teach and report, so the model recognizes a policy denial identically whether the kernel refused a bash file effect or the filesystem provider's fence refused a mutation

内核拒绝和进程内围栏拒绝,模型看到同一个标记。 第 8 章讲过两者内部表示完全不同(stderr 方言分类 vs 结构化 FS_SANDBOX_DENIED)——统一发生在表面。

升级提示的注释更重要:

the nudge lives at the decision point so the sanctioned retry does not depend on the model recalling the tool description.

提示放在决策点,不依赖模型记得工具描述。 工具描述在 prompt 顶部几千 token 之前;拒绝发生在此刻。把"你现在可以这样做"放在拒绝结果里,而不是指望模型回想。

subject 参数让两个家族用自己的名词:bash 说 command,fs 说 operation

5.5 提示词里的跨调用纪律

bashDescription()tool-bash/src/index.ts:70)在有升级能力时追加的那段,值得整段读:

Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.

这段话在管六件事:

  1. 试是安全的——不要凭策略预判拒绝(呼应策略 context 那句"别只凭策略就拒绝");
  2. 同轮升级,不要绕到聊天里请求许可——审批弹窗就是用户同意的方式;
  3. 最窄的够用模式
  4. 不要投机升级——必须有真实的拒绝作为依据;
  5. 被拒就是终局(对这条命令),不许绕路——但不禁止之后别的命令再试;
  6. never 策略下没有例外

第 2 和第 5 条是对两种相反的失效模式的补丁:一种是模型太客气(先在聊天里问,用户还得回一句"可以"),一种是模型太激进(一上来就要 danger-full-access)。

sandbox_permissions 字段只有在执行器确实限制时才出现在 schema 里:

const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && sandboxPolicy === undefined) {
  throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}

第 8 章那个"能力问答方法"在这里落地成三件事:schema 加不加字段、要不要 policy 服务、以及一个组合校验——执行器会限制但策略服务缺席,在工具插件加载时就失败,不是等到第一次调用。

还有一个防御性细节,注释写在 approveBashEscalation 上:

the fields are unadvertised without a sandboxing executor, yet schema validation checks advertised keys only, so an unadvertised sandbox_permissions still reaches execute

没有广告的参数照样能到达执行层(schema 校验只查广告过的键)。所以执行层要自己再挡一次:sandbox_permissions is not available in this composition (no sandboxing executor to escalate)


6. Permission Presets:把两个旋钮打包给人看

ctx.permissionPresets 解决的是产品问题:用户不该被要求分别理解"沙箱模式"和"审批策略"。

默认两个预设:

  • workspace-write = workspace-write + ask
  • danger-full-access = danger-full-access + never

set(session, name) 的顺序有讲究:

records a changed selection in a log-only permissionPresets/preset event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing.

先记意图,再记机制。 两个预设可能包含相同的旋钮组合,此时只记旋钮就丢掉了"用户选的是哪个预设"。而净零切换什么都不追加。

current(events) 的解析顺序:prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns custom

custom只能推导、不能选择的:

Clients may display custom as the current value, but cannot select it.

用户直接改单个旋钮就会落到 custom,UI 要能显示这个状态,但它不是一个可选项。

会话创建时的固定行为也值得注意:

A committed Settings change is read when the next session is created; creation pins permissionPresets/preset, sandbox/mode, and approval/policy into that session, so later changes never alter an existing session.

权限在会话创建时被钉进日志。 用户后来改了默认值,不会影响已存在的会话。而恢复的会话(包括被 session/end-seed 标记的空 seed)保留自己的有效权限,只补缺失的持久事实——不会被"最新的用户默认值"覆盖。

还有一个 HMR 细节:挂载服务时会扫一遍已存在的活会话,给插件缺席期间创建的会话补钉。


7. Provider 侧:三个平台,一个 fail-closed

sandbox-local 的选择逻辑(README):

Linux prefers a working bwrap then Landlock; macOS uses Seatbelt; Windows uses the ACL restricted-token runner. Multiple candidates are probed in order, while a sole candidate is selected directly.

Unsupported platforms and unusable runners fail closed with SANDBOX_UNAVAILABLE; execution never silently falls through unconfined.

最后半句是这个包的核心承诺。而且探测是功能性的,不只是"文件存不存在"——Seatbelt 那条说得很有意思:

Apple marks the sandbox-exec CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.

依赖一个被标为 deprecated 的工具,但用功能探测兜底。哪天 Apple 真的移除了,结果是 fail closed 而不是静默不限制。

三个后端的诚实自评也都写了下来:

后端 声明的限制
Windows ACL enforcement: 'partial'——受限令牌必须保留 Everyone 才能初始化进程,所以对 Everyone 可写的外部对象仍可写;NTFS 硬链接也能跨路径别名同一个文件对象
Landlock 较老的内核 ABI 只能限制它暴露的访问类别,报 partial
Seatbelt 依赖 deprecated 的 sandbox-exec,无法替代或探测那个私有策略引擎
自定义 runnerCommand an operator assertion——跳过功能探测,假定它诚实实现了 bwrap 兼容 profile

enforcement: 'full' | 'partial' 这个字段的类型注释(sandbox/src/index.ts:58)说得很直接:

partial means an active backend or older kernel ABI cannot govern every promised file effect; callers requiring an absolute boundary must not treat it as full.

把"我只做到了部分"做成一个类型字段,而不是一句 README 里的免责声明。 需要绝对边界的调用方可以在代码里检查它。

bwrap profile 里有一个容易忽略的点:

hiding host /proc/<pid> entries prevents magic links such as root and fd from bypassing its mounts

私有 PID namespace 不只是"看不见别的进程"——/proc/<pid>/root/proc/<pid>/fd 是能绕过挂载视图的魔法链接。这是一个 bug-fix 的 Agent Note 留下的边界。

Windows 那条的设计也很细:每个工作区一个确定的写 SID 和常驻 ACE,但每个活着的会话/工作区对拿一个随机私有临时目录和独立 SID。所以共享工作区的会话共享工作区写权限,却不继承彼此的临时目录权限。而且新 provider 总是选新的临时路径和 SID,so crash residue cannot block or authorize a resumed session


8. 设计代价

只有一次性授权,没有"总是允许"。 频繁需要同一个权限的工作流会反复弹窗。换来的是没有授权存储、没有撤销语义、没有"我什么时候批过这个"的困惑。

三个值的模式词汇管不了网络。 一个 read-only 的会话可以随便发网络请求。这个边界写清楚了,但它意味着"沙箱"这个词在 dsh 里比一般人以为的窄。

升级依赖模型的合作。 严格加宽、参数配对、审批都是强制的,但"最窄的够用模式""不要投机升级"只能靠提示词。模型每次都要 danger-full-access,机制上挡不住——只有人在弹窗里挡。

三个平台后端的实际保证不同,而模式词汇是同一套。 workspace-write 在 bwrap 下是临时 /tmp 挂载,在 Landlock 下是宿主 /tmpenforcement: partial 是对这个差异的诚实标注,但它要求调用方真的去看那个字段。

runner 选择在 provider 生命周期内缓存。 装上、卸掉或修好一个 runner 需要重载插件才生效。

审批必须在打开的 turn 里。 跨 turn 的持久审批工作流("我下班了,明早批")目前做不到,README 明确列为 deferred。

策略 context 只描述策略,不列举能力。 这是正确的所有权划分,但模型因此要靠"试一下"来发现哪些操作被限制——所以才需要那句"别只凭策略就拒绝"。

五个包协作一件事。 模式、策略、provider、审批、预设。加一个权限维度(比如网络)要动其中大部分。


9. 可迁移的经验

1. 一个安全词汇必须写明它不覆盖什么。 "三个值只管文件效果,网络和进程可见性不在其中"——没这句,read-only 会被读成"安全"。

2. 默认值取 fail-safe,放宽必须显式 opt in。 mode: 'read-only' 是默认,想让 agent 能写工作区要在配置里明确写。

3. 状态变更就是一个日志事件,不要内存镜像。 effective = 显式授权 ?? fold(事件) ?? 部署默认。重启后 replay 得到同一答案,两个会话天然隔离,也不存在"日志和内存不一致"这个 bug 类别。

4. 路径规范化的顺序是安全属性。 先解析符号链接再做词法归一化,而且用原生 realpath——反过来会让围栏检查的路径和内核访问的路径不同。解析失败时用原样(匹配不到任何东西),别编 fallback。

5. 一个必须确定成立的策略,要在服务自己的路径里判定。never 实现成监听器,会让"一定拒绝"退化成"取决于注册顺序",因为后来者可以 prepend

6. 所有失败路径归到同一个 fail-closed 结果,但保留不同的解释文本。 没有 answerer / answerer 抛错 / 返回了词汇外的值 → 都是 unavailable;而"人拒绝了"和"通道没配"给模型不同的话,因为这两者该导致不同的下一步。

7. 同步抛错和异步抛错要走同一条路。 Promise.resolve().then(() => f()),不是 Promise.resolve(f())——后者会让 f() 的同步异常穿透容器。

8. 审计记录成对写,写不上就不算决定。 宁可让调用失败,也不返回一个没有审计记录的批准。而且审计对必须在一个打开的 turn 里,否则崩溃恢复会把它当垃圾丢掉。

9. 把"你现在可以这么做"放在拒绝结果里,不要指望模型回想工具描述。 提示放在决策点。

10. 权限提示要同时反向纠偏。 告诉模型策略,也告诉它"别只凭策略就在聊天里放弃";给它升级能力,也告诉它"别投机升级""被拒就是终局"。一个只说规则不说边界的提示,会同时产生太客气和太激进两种失效。

11. schema 用注册表级的闭合词汇,按调用的真相在执行时检查。 按部署默认裁剪 enum 会让切窄了的会话彻底失去杠杆。

12. 用结构化类型代替包依赖。 EscalationApprover<A, C> 让 sandbox 包能通过 ctx.approval 解析升级,却不 import 审批包和 agent 包。

13. 组合错误在加载时失败,不要等第一次调用。 "执行器会限制但策略服务缺席"→ 工具插件加载即抛。

14. 把"部分实施"做成类型字段。 enforcement: 'full' | 'partial' 比 README 里的免责声明有用,因为调用方可以在代码里检查它。

15. 先记用户意图,再记机制变更。 两个预设可能有相同的旋钮组合;只记旋钮就丢掉了"他选的是哪个"。

16. 权限在会话创建时钉进日志。 后来改默认值不影响已有会话——恢复一个旧会话不会悄悄给它更宽的权限。


10. 本章源码位置

位置 内容
packages/sandbox/sandbox/src/index.ts:29 SandboxMode:三个值;网络和进程可见性明确在外
packages/sandbox/sandbox/src/index.ts:58 SandboxEnforcementpartial 的含义与"不要当 full 用"的告诫
packages/sandbox/sandbox/src/roots.ts:30 canonicalPath()realpathSync.native 的理由;失败用原样
packages/sandbox/sandbox/src/roots.ts:52 writableRoots():两个执行家族的唯一可写根来源
packages/sandbox/sandbox/src/escalation.ts:1 模块注释:为什么升级编排独立成包,结构化类型代替包依赖
packages/sandbox/sandbox/src/escalation.ts:28 WIDER_MODES:严格加宽表,执行时检查
packages/sandbox/sandbox/src/escalation.ts:41 ESCALATION_TARGETS:为什么 schema enum 不按部署默认裁剪
packages/sandbox/sandbox/src/escalation.ts:51 validateEscalationArgs():双向配对 + 非空理由
packages/sandbox/sandbox/src/escalation.ts:71 sandboxDenialMarker():两个家族共享的模型可见拒绝标记
packages/sandbox/sandbox/src/escalation.ts:84 escalationHintMarker():提示放在决策点
packages/sandbox/sandbox/src/escalation.ts:93 EscalationOutcome:与审批 seam 结构相同以避免 import
packages/sandbox/sandbox/src/escalation.ts:157 approveEscalation():有序 fail-closed 序列;四条逐字文本
packages/sandbox/sandbox-policy/src/index.ts:38 renderPolicyContext():三段能力中立的策略散文
packages/sandbox/sandbox-policy/src/index.ts:135 resolve():三层优先级;会话 cwd 作为工作区边界
packages/sandbox/sandbox-policy/src/session-mode.ts:70 setSandboxMode():切换就是一个 sandbox/mode 事件
packages/sandbox/sandbox-policy/README.md 共享策略家的理由;临时区刻意不枚举;KV cache 影响
packages/sandbox/sandbox-local/README.md 三平台 runner 选择、功能探测、fail-closed、各后端诚实自评
packages/interaction/user-approval/src/index.ts:226 setPolicy():切换用 inject(不唤醒)告知模型
packages/interaction/user-approval/src/index.ts:257 request():turn 封闭检查 + 成对审计追加
packages/interaction/user-approval/src/index.ts:304 decide()never 在 dispatch 前判定;三种 fail-closed;取消竞速
packages/interaction/user-approval/README.md 只有一次性授权;一个终端 answerer;两种策略的模型可见文本
packages/interaction/permission-presets/README.md 先记意图后记机制;custom 只能推导;创建时钉住权限
packages/shell/tool-bash/src/index.ts:70 bashDescription():升级纪律的六条
packages/shell/tool-bash/src/index.ts:193 能力问答 → schema 字段 + 组合校验(加载即失败)
packages/shell/tool-bash/src/index.ts:213 approveBashEscalation():未广告参数仍需执行层拦截