工具与安全边界:先关进沙箱再问
pi 不弹权限框:工具以启动它的账户权限直接跑,要安全就放进容器,或者自己写扩展在 tool_call 上拦下来逐条确认1。前一种全有全无;后一种每条都要用户判断,判断烦了就全局放开。dsh 把顺序倒过来:先把命令关进内核沙箱跑,真被拒了,模型才能为这一条命令申请一次放宽。沙箱部分在本机真跑(Linux 5.15,没装 bwrap,落到 Landlock)。
先关进沙箱:每次调用自带策略,没有后端就不跑
问题:首选的 bwrap 恰恰在最需要沙箱的主机上用不了——精简容器、禁用了非特权 user namespace、LSM 拒绝 mount。设计记录因此要求备选 runner 随 SDK 交付,不能假设主机已有。
ctx.sandbox.confine(argv, policy) 把要 spawn 的 argv 包一层交回,调用方拿它替换原 argv。策略跟着每次调用走:同一时刻 bash 可以按 read-only 跑,子 agent 按 workspace-write 跑。模式三档,只管写——读一律放行,网络不在词表里:
/**
* 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.
*/
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
每个平台一条后端链,同一份策略在三个平台翻成各自的参数:
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
linux: ['bwrap', 'landlock'],
darwin: ['seatbelt'],
// The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):
// a sole candidate, selected without a probe — its execution-time refusal
// fails closed through its stderr signature (windows-acl-run:) and exit 127.
win32: ['windows-acl'],
}
## workspace-write · bwrap
bwrap --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent --tmpfs /tmp --bind /home/me/project /home/me/project
## workspace-write · Landlock
landlock-run --ro / --rw /dev/null --rw /tmp --rw /home/me/project
## workspace-write · Seatbelt
sandbox-exec -p (version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null")) (allow file-write* (subpath "/home/me/project") (subpath "/tmp"))
## read-only · bwrap
bwrap --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent
## read-only · Landlock
landlock-run --ro / --rw /dev/null
## read-only · Seatbelt
sandbox-exec -p (version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))
Landlock 这一档是仓库自带的约 300 行 C:给自己装上规则再 exec 目标命令,规则随 execve 继承。内核不支持就失败退出;旧 ABI 只能部分强制执行,照跑,但每次在 stderr 报一行:
int partial = 0;
code = restrict_self(&cli, &partial);
if (code != 0) return code;
if (partial) {
/* Older ABI: some handled accesses are not governed (e.g. truncate
* before ABI 3). Still confined for everything the kernel supports —
* report, do not refuse. */
fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n");
}
execvp(cli.command[0], cli.command);
/* exec only returns on failure. */
return fail("exec failed", strerror(errno));
}
本机是 ABI 1。装上真的提供方,看选中的后端和包装后的 argv:
built /tmp/dsh-lab/tools/landlock-run
landlock: partially enforced (older ABI)
{
"argv": [
"<landlock-run>",
"--ro",
"/",
"--rw",
"/dev/null",
"--rw",
"/tmp",
"--rw",
"<ws>",
"--",
"bash",
"-c",
"echo hi"
],
"enforcement": "partial",
"denialSignatures": [
"permission denied"
],
"runnerFailureRules": [
{
"allowedExitCodes": [
125
],
"fatalSignatures": [
"landlock-run: "
],
"informationalLines": [
"landlock-run: partial enforcement (older Landlock ABI)"
]
}
]
}
同一个组合真跑几条命令,「模型拿到」是渲染给模型的原文:
# 工作区内写(workspace-write)
$ echo inside > inside.txt && cat inside.txt
sandbox: {"mode":"workspace-write","denied":false,"enforcement":"partial"}
--- 模型拿到 ---
inside
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
# 工作区外写($HOME)(workspace-write)
$ echo outside > $HOME/dsh-landlock-outside.txt
sandbox: {"mode":"workspace-write","denied":true,"enforcement":"partial"}
--- 模型拿到 ---
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
bash: line 1: $HOME/dsh-landlock-outside.txt: Permission denied
[sandbox: file access denied under workspace-write mode]
[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]
[exit code: 1]
# 读不受限(workspace-write)
$ wc -l < /etc/hosts
sandbox: {"mode":"workspace-write","denied":false,"enforcement":"partial"}
--- 模型拿到 ---
12
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
# read-only 下连工作区也不能写(read-only)
$ echo x > ro.txt
sandbox: {"mode":"read-only","denied":true,"enforcement":"partial"}
--- 模型拿到 ---
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
bash: line 1: ro.txt: Permission denied
[sandbox: file access denied under read-only mode]
[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]
[exit code: 1]
# 跑完检查:$HOME/dsh-landlock-outside.txt 不存在
把启动器路径指到不存在的文件,本机一个后端都没有,confine() 直接抛错:
SandboxUnavailableError [SANDBOX_UNAVAILABLE]
sandbox mode "workspace-write" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access.
被拒了才申请一次放宽
问题:设计记录2的原话——「拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 workspace-write 或 danger-full-access,从而使沙箱形同虚设。」
被拒时,结果末尾带两行标记:一行说被拒,一行提示可以原样重试一次。提示放在结果里,不靠模型记得工具说明——上面工作区外那条命令的最后三行就是:
export function sandboxDenialMarker(mode: SandboxMode): string {
return `[sandbox: file access denied under ${mode} mode]`
}
/**
* The same-turn escalation hint that rides a denial when the composition
* advertises the escalation fields — the nudge lives at the decision point so
* the sanctioned retry does not depend on the model recalling the tool
* description.
* @param subject - the family's noun for the denied action (`command` for
* bash, `operation` for a filesystem mutation).
* @returns the hint line, exactly as the model sees it.
*/
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]`
}
重试时 bash 多带 sandbox_permissions(要哪一档)和 justification(给用户看的理由)。档位只能往宽里走:
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
const { requestedMode: mode, effectiveMode, justification, subject } = request
if (mode === effectiveMode) return effectiveMode
// Strict widening is an EXECUTION check against the call's effective mode —
// deliberately not a schema constraint (the enum is the closed target
// vocabulary; the effective mode is per-call truth).
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`)
}
// Self-contained for the audit trail: approval/asked stores this reason,
// and the target mode is part of the grant's identity.
const outcome = await approval.approver.request({
agent: approval.agent,
toolName: approval.toolName,
callId: approval.callId,
reason: `escalate sandbox to ${mode}: ${justification}`,
displayReason: {
en: `Allow this operation with ${mode} permissions: ${justification}`,
zh: `允许本次操作使用 ${mode} 权限:${justification}`,
},
...approval.signal ? { signal: approval.signal } : {},
})
switch (outcome) {
// The schema enum already pinned `mode` to the closed target vocabulary;
// the check above proved it is strictly wider.
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`)
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')
}
}
批下来的档只盖在这一次调用上,不写进会话策略:
async execute(args: BashToolArgs, exec) {
// Description is display metadata; workdir defaults to the caller's session.
const standingPolicy = resolveSandboxPolicy(exec)
validateBashArgs(args, standingPolicy?.mode)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
用真的审批服务跑各种回答,agent 和回答方是构造的:
用户批准一次
审计:asked → decided allowed-once
结果:放行,本次按 danger-full-access 跑
用户拒绝
审计:asked → decided rejected
结果:报错 the user rejected escalating this command to "danger-full-access"; it stays denied, so stop and explain instead of working around it
没人回答(没有界面)
审计:asked → decided unavailable
结果:报错 sandbox escalation to "danger-full-access" requires approval, but no approval channel is available
回答方抛错
审计:asked → decided unavailable
结果:报错 sandbox escalation to "danger-full-access" requires approval, but no approval channel is available
回答方返回词表外的值
审计:asked → decided unavailable
结果:报错 sandbox escalation to "danger-full-access" requires approval, but no approval channel is available
会话策略是 never:回答方想批也没用
审计:asked → decided rejected
结果:报错 the user rejected escalating this command to "danger-full-access"; it stays denied, so stop and explain instead of working around it
申请和当前一样的档:不问
审计:(没有问)
结果:放行,本次按 workspace-write 跑
申请更窄的档:拒收
审计:(没有问)
结果:报错 sandbox escalation to "read-only" is not strictly wider than this call's current "workspace-write" mode
没装审批服务
审计:(没有问)
结果:报错 sandbox escalation to "danger-full-access" requires approval, but no approval service is composed
只有 allowed-once 放行;没人回答、回答方抛错、答了词表外的值,都按没有审批通道拒绝。
上游录过两段真会话:用户提示是构造的,明说要带放宽重试;模型回复是 deepseek-v4-flash 的真输出。用上游的快照测试重放,日志和仓库里提交的逐条对上:
1 permission/preset {"preset": "workspace-write"}
2 sandbox/mode {"mode": "workspace-write"}
3 approval/policy {"policy": "ask"}
14 assistant/message (tool call)
15 tool/call bash command="printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt"
sandbox_permissions='danger-full-access' justification='the user asked to write a file outside the workspace'
16 approval/asked reason='escalate sandbox to danger-full-access: the user asked to write a file outside the workspace'
17 approval/decided outcome=allowed-once
20 tool/result isError=False 'escalated\n'
23 assistant/message text='DONE'
1 permission/preset {"preset": "workspace-write"}
2 sandbox/mode {"mode": "workspace-write"}
3 approval/policy {"policy": "ask"}
14 assistant/message (tool call)
15 tool/call bash command="printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt"
sandbox_permissions='danger-full-access' justification='the user asked to write a file outside the workspace'
16 approval/asked reason='escalate sandbox to danger-full-access: the user asked to write a file outside the workspace'
17 approval/decided outcome=rejected
18 tool/result isError=True 'Error: the user rejected escalating this command to "danger-full-access"; it stays denied, so stop and explain instead of working around it'
21 assistant/message text='The user rejected the permission escalation, so this command cannot be run.'
「必须先被拒过才能申请」只写在给模型看的参数说明里,代码不查,把关的是审批弹窗。
分清三种失败:启动器坏了、被拒、命令自己失败
问题:上游复盘 0004——旧 ABI 上启动器每次都打一行无害提示,规则若只是「带 landlock-run: 前缀 + 非零退出 = 启动器坏了」,false、ripgrep 没匹配的退出 1 就全被报成沙箱不可用。
三种分开报:启动器失败说明命令没跑,抛 SANDBOX_UNAVAILABLE;被拒打上面两行标记;命令自己失败只报退出码。判启动器失败要三样证据同时成立——退出码在允许集合里(Landlock 只认 125)、按整行精确排除提示行、剩下某行带致命前缀。被拒按各后端自己的报错特征判:
const DENIAL_SIGNATURES = {
bwrap: ['read-only file system'],
landlock: ['permission denied'],
seatbelt: ['operation not permitted'],
// pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied.";
// Node EACCES: "permission denied"; EPERM: "operation not permitted".
'windows-acl': ['access is denied', 'access to the path', 'permission denied', 'operation not permitted'],
runnerCommand: ['read-only file system', 'permission denied'],
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
const RUNNER_FAILURE_RULES = {
bwrap: [{ fatalSignatures: ['bwrap: '] }],
landlock: [{
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
fatalSignatures: [`${LAUNCHER_BIN}: `],
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
}],
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }],
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
先判启动器失败,再判被拒:
return SandboxBashExecutor.decorateResult(ex, (result) => {
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } }
const { enforcement, denialSignatures, runnerFailureRules } = confined
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, denialSignatures), enforcement } }
# 命令自己失败:false(workspace-write)
$ false
sandbox: {"mode":"workspace-write","denied":false,"enforcement":"partial"}
--- 模型拿到 ---
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
[exit code: 1]
# 命令自己失败:grep 没匹配(同 ripgrep 无匹配退出 1)(workspace-write)
$ grep -q no-such-text /etc/hosts
sandbox: {"mode":"workspace-write","denied":false,"enforcement":"partial"}
--- 模型拿到 ---
[stderr]
landlock-run: partial enforcement (older Landlock ABI)
[exit code: 1]
同一个分类函数,喂本机 confine() 返回的真规则,和照复盘构造的「只认前缀」规则对照:
# 子进程 false:提示行 + 退出 1
退出码 1,stderr:"landlock-run: partial enforcement (older Landlock ABI)\n"
真规则 → 命令跑了(不算启动器失败)
只认前缀 → 启动器失败:landlock-run: partial enforcement (older Landlock ABI)
# 子进程自选退出 125:只有提示行
退出码 125,stderr:"landlock-run: partial enforcement (older Landlock ABI)\n"
真规则 → 命令跑了(不算启动器失败)
只认前缀 → 启动器失败:landlock-run: partial enforcement (older Landlock ABI)
# 启动器真坏了:退出 125 + 致命行
退出码 125,stderr:"landlock-run: landlock ruleset error: Invalid argument\n"
真规则 → 启动器失败:landlock-run: landlock ruleset error: Invalid argument
只认前缀 → 启动器失败:landlock-run: landlock ruleset error: Invalid argument
工具管线:守卫只能否决,审批没答复就算拒
问题:扩展点能随意改决定和参数时,后注册的插件能把前面拒掉的调用改回放行;改了参数,日志里记的调用又和真执行的对不上。
一次调用依次过:tools/pre-execute 瀑布 → 守卫 → tools/execute 包装(超时、重试、计量)→ 工具体 → tools/post-execute → tools/result。前置决定只有四种,没有改参数——参数执行前已经记进日志、显示给用户了:
/**
* Pre-dispatch decision. `allow` runs the call; `deny` materializes its
* model-facing reason and optional structured error identity; `cancel` selects
* the canonical cancellation result without presenting a policy denial; `ask`
* runs only after an approval service returns `allowed-once` and otherwise
* denies; its `reason` is the audited approval reason and its optional
* `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and
* presented.
*/
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
| { kind: 'cancel' }
| { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } }
守卫在所有监听器之后跑,只能返回拒绝理由:
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
* @param execution - the identity-protected call after extensible pre-execute policy completed.
* @returns a final denial reason, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const askResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
if (decision.kind === 'cancel') {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason
const denialInfo = decision.kind === 'deny' ? decision.info : undefined
if (denialReason !== undefined) {
return await next({
kind: 'post-result',
exec,
result: this.materializeFinalResult({
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } },
}),
})
}
审批服务把 never 判在派发前;回答方抛错或乱答,一律收成 unavailable:
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
const signal = req.signal
if (signal?.aborted) return 'cancelled'
// 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'
// Enter the promise chain BEFORE dispatching: 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.
const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
() => this.ctx.waterfall(
scopeTarget(req.agent, req.agent), 'approval/request', req,
() => Promise.resolve<ApprovalOutcome>('unavailable'),
),
).then(
// Normalize a rogue (non-vocabulary) answerer return to the fail-closed
// outcome instead of leaking it into callers' closed-union switches.
outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
// A throwing answerer must fail the QUESTION closed, not the caller's
// tool call open — the seam contains its callbacks.
() => 'unavailable',
)
if (signal === undefined) return answer
用真的工具注册表,注册一个构造的 delete_path,挂不同的监听器去删 /etc/passwd:
1. 什么都不挂
工具体跑了吗:跑了
模型拿到:deleted /etc/passwd
2. 挂一个策略监听器:工作区外就 deny
工具体跑了吗:没跑
模型拿到:Error: refusing to touch /etc/passwd outside the workspace
3. 再来一个插件,prepend 进最外层,直接 allow 不调 next()
工具体跑了吗:跑了
模型拿到:deleted /etc/passwd
4. 同样两个监听器,策略改成守卫(只能否决)
工具体跑了吗:没跑
模型拿到:Error: refusing to touch /etc/passwd outside the workspace
5. 监听器想把参数改成安全路径
工具体跑了吗:没跑
模型拿到:Error: Cannot assign to read only property 'path' of object '#<Object>'
6. 监听器要 ask,但没装审批服务
工具体跑了吗:没跑
模型拿到:Error: delete_path outside the workspace
7. 监听器要 ask,审批服务在,但没有回答方
工具体跑了吗:没跑
模型拿到:Error: tool "delete_path" requires approval, but no approval channel is available
审计:approval/asked → approval/decided unavailable
第 3 条:外层监听器不调 next() 就盖掉了里层的拒绝;第 4 条换成守卫,顺序就翻不了盘;第 5 条的参数是冻结的。
权限记进日志,原样告诉模型
问题:上游设计记录2里的实测——read-only 会话里 write、edit 的 schema 照样可见,「模型会声称自己能够写入,直到一次被拒绝的调用后才发现事实并非如此」。反过来,只读写成绝对化的声明,模型又直接用文字拒绝、一个工具都不调。
沙箱模式和审批策略各是一个只记日志的事件(sandbox/mode、approval/policy),三档预设是两个旋钮的组合:
- id: permission
name: '@deepseek-ai/dsh-permission-presets'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
每次请求从会话日志现算当前策略,渲染成一段话随运行时上下文发出(位置见拼 prompt)。read-only 那句叮嘱:别单凭策略就拒绝,照常调工具,按工具返回的提示走:
/** Render the policy without claiming which capabilities are mounted. */
function renderPolicyContext(policy: SandboxExecutionPolicy): string {
switch (policy.mode) {
case 'read-only':
return '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.'
case 'workspace-write':
return `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(policy.workspaceRoot)}. Some platform temporary areas may also be writable.`
case 'danger-full-access':
return 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.'
/* v8 ignore next 4 -- SandboxMode is a typed same-process closed union; this branch is only the static exhaustiveness guard. */
default: {
const mode: never = policy.mode
throw new Error(`unreachable sandbox mode: ${String(mode)}`)
}
}
}
/**
* Resolve the complete policy for one capability call. An approved explicit
* mode outranks the session's last `sandbox/mode` event, which outranks the
* deployment default. A session cwd is its workspace-write boundary; the
* configured root is the fallback for agentless calls and sessions without a
* cwd.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
*/
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 },
}
}
用真的会话,像用户在界面上切预设那样追加事件,每次重新组装:
# 新会话,用部署默认值
会话日志:
(空)
模型看到:
Current runtime context. This snapshot supersedes earlier runtime-context snapshots.
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "/home/me/project". Some platform temporary areas may also be writable.
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
# 用户切到 read-only
会话日志:
sandbox/mode {"mode":"read-only"}
模型看到:
Current runtime context. This snapshot supersedes earlier runtime-context snapshots.
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.
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
# 用户切到 danger-full-access + never
会话日志:
sandbox/mode {"mode":"read-only"}
sandbox/mode {"mode":"danger-full-access"}
approval/policy {"policy":"never"}
模型看到:
Current runtime context. This snapshot supersedes earlier runtime-context snapshots.
Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
策略只有日志一个来源,没有「上次告诉过模型什么」的进程状态,恢复、fork 之后权限跟着日志走。
先读后改,写入带版本
问题:进程内的 write、edit 不经过 argv 包装,内核沙箱管不到。模型可能改一个没读过的文件,也可能覆盖用户刚在编辑器里改过的版本。
观察策略记下每个会话读到的每个文件版本:没读过就 edit 或覆盖,拒;读过就带着版本号去写,期间文件被改过,拒:
/**
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;
* confirmed present ⇒ `replaceIfVersion` at the observed version.
*/
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior?.kind === 'present'
? { kind: 'replaceIfVersion', version: prior.version }
: { kind: 'createIfAbsent' }
}
/**
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the
* observed version as the CAS basis.
*/
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || prior === undefined) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
if (prior.kind === 'absent') {
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
return { version: prior.version }
}
版本比对在提供方的文件锁里做;报给模型的话统一附上补救办法:
if (expected?.kind === 'replaceIfVersion') {
// Stale guard: the file must still exist at the version the owner observed.
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
if (existing.version !== expected.version) {
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
} else if (expected?.kind === 'createIfAbsent' && existing) {
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
export function remediateFsError(error: unknown, displayPath: string): unknown {
if (!(error instanceof FsError)) return error
if (error.code === 'FS_NOT_OBSERVED') {
return new FsError(
`cannot modify "${displayPath}": file has not been read — read the file, then retry`,
error.code,
{ cause: error },
)
}
if (error.code === 'FS_STALE_VERSION') {
return new FsError(`${error.message} — re-read the file, then retry`, error.code, { cause: error })
}
return error
}
用真的工具和本地文件系统跑;假 agent、「用户中途改文件」是构造的:
1. 没读过就改
edit {"file_path":"a.txt","old_string":"red","new_string":"blue"}
报错 [FS_NOT_OBSERVED]:Error: cannot modify "<ws>/a.txt": file has not been read — read the file, then retry
2. 没读过就整篇覆盖
write {"file_path":"a.txt","content":"color = green\n"}
报错 [FS_NOT_OBSERVED]:Error: cannot modify "<ws>/a.txt": file has not been read — read the file, then retry
3. 先读
read {"file_path":"a.txt"}
成功:<path><ws>/a.txt</path>
<type>file</type>
<content>
1: color = red
(End of file - total 1 lines)
</content>
(用户这时在编辑器里给 a.txt 加了一行 size = 3)
4. 拿着旧版本去改
edit {"file_path":"a.txt","old_string":"red","new_string":"blue"}
报错 [FS_STALE_VERSION]:Error: cannot edit "<ws>/a.txt": file changed since it was read — re-read the file, then retry
5. 重新读
read {"file_path":"a.txt"}
成功:<path><ws>/a.txt</path>
<type>file</type>
<content>
1: color = red
2: size = 3
(End of file - total 2 lines)
</content>
6. 再改
edit {"file_path":"a.txt","old_string":"red","new_string":"blue"}
成功:The file <ws>/a.txt has been updated successfully.
最终文件内容:"color = blue\nsize = 3\n"
大输出落盘,结果带标记
问题:抓回来的网页、几千行日志整段塞进下一次请求,可之后也许还要回头查。
落盘策略挂在 tools/post-execute 最外层:结果估算超过 maxInlineTokens(base 组合包是 12500)就把全文写进会话私有的文件,模型拿头尾预览加一句路径提示。read 自己不落盘,否则读落盘文件又会被落盘:
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const decision = await next()
if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value') || exec.name === 'read') return decision
const content = decision.content ?? result.content
const hasImages = content.some(block => block.type === 'image')
// Text-only PTC bindings retain their asynchronous log-only spill path.
if (exec.parent !== undefined && !hasImages) return decision
const retained = await bound(exec, content, exec.name, exec.callId, exec.parent === undefined ? 'result' : 'dispatch')
if (retained === undefined) return decision
const additionalContexts = [...decision.additionalContexts ?? []]
if (exec.parent !== undefined && !result.isError && hasImages && !retained.some(block => block.type === 'image')) {
additionalContexts.push(createUserMessage({ content: retained, source: { kind: 'ptc-mode' } }))
}
return {
kind: 'accept', content: retained,
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}, { prepend: true })
export function formatSpillNotice(omitted: Omitted, ref: Pick<SpillRef, 'locator' | 'retrievalHint'>, images = 0): string {
const imageNotice = images > 0 ? ` Omitted ${images} images.` : ''
return `${OPEN}${describeOmitted(omitted, 'bytes')}${imageNotice}${LOCATION}${ref.locator}${GUIDANCE_SEPARATOR}${ref.retrievalHint}${CLOSE}`
}
用真的落盘策略和本地存储跑一个吐 400 行日志的构造工具,上限设成 300 好放进页面:
2026-10-20T12:00:00 worker-0 processed batch 0 ok
2026-10-20T12:00:01 worker-1 processed batch 1 ok
2026-10-20T12:00:02 worker-2 processed batch 2 ok
2026-10-20T12:00:03 worker-3 processed batch 3 ok
2026-10-20T12:00:04 worker-4 processed batch 4 ok
2026-10-20T12:00:05 worker-5 processed batch 5 ok
2026-10-20T12:00:06 worker-6 processed batch 6 ok
2026-10-20T12:00:07 worker-0 processed batch 7 ok
2026-10-20T12:00:08 worker-1 processed batch 8 ok
2026-10-20T12:
[...]
-10-20T12:00:31 worker-6 processed batch 391 ok
2026-10-20T12:00:32 worker-0 processed batch 392 ok
2026-10-20T12:00:33 worker-1 processed batch 393 ok
2026-10-20T12:00:34 worker-2 processed batch 394 ok
2026-10-20T12:00:35 worker-3 processed batch 395 ok
2026-10-20T12:00:36 worker-4 processed batch 396 ok
2026-10-20T12:00:37 worker-5 processed batch 397 ok
2026-10-20T12:00:38 worker-6 processed batch 398 ok
2026-10-20T12:00:39 worker-0 processed batch 399 ok
(Omitted 19762 bytes. Full formatted result stored at: <spill-root>/session-e8bc163c82ee/b05aada66e9d-fetch_log.txt. Use read with offset/limit, or grep this path to search within it.)
bash 另有一套标记:非零退出不算工具出错,只标退出码,由模型判断;前台超时默认不杀进程,转成后台任务,完成时再通知:
const markers: string[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
if (escalationModes.length > 0) {
markers.push(escalationHintMarker('command'))
}
}
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
// A kill from outside the call (the human stopping its job) is not a
// command failure: the reason tells the model not to retry.
if (result.stopped !== undefined) markers.push(`[stopped: ${result.stopped}]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
# 成功、没输出
(no output)
# 退出码非零
[stderr]
tsc: 3 errors
[exit code: 2]
# 输出太长被截到尾部
…last lines of the build log
[output truncated; full output: /tmp/dsh-spill/session-1/bash-stdout.txt]
# 超时(不转后台的部署)
(no output)
[timed out after 60000ms]
[killed by signal: SIGTERM]
# 被人从界面上停掉
(no output)
[stopped: stopped by the user]
[killed by signal: SIGTERM]
# 超时转后台(默认)
Compiling 412 modules...
[still running after 60000ms; moved to background job job_3]
The command keeps running in the background. You will be notified when it finishes; read newer output with job_output, stop it with job_kill.
两个可选项
- PTC(programmatic tool calling):模型写代码批量调工具,代码跑在新起的 Node 子进程里,同样过
confine()(packages/ptc-runtime/ptc-runtime-node/src/index.ts:219),每次tools.x()重走整条管线(packages/core/tools/src/ptc.ts:539-556) - Auto review:实验性,默认关。调用前让模型评一次风险,放行的以完全权限执行,被拒的转成问用户
这套边界管不到什么
- 上游自称未审计:
SAFETY.zh.md写明沙箱「不保证隔离」 - 只限写:读整个文件系统、访问网络都不拦
- 进程内的 write、edit 只有路径围栏:源码注释自认是 containment、不是安全边界(
packages/fs/fs-sandbox/src/index.ts:10-18) - 旧内核管不全:Landlock 在 ABI 3 之前不管 truncate
谁管什么
- 程序:沙箱包装、没后端就拒、失败分类、守卫和审批关死、从日志现算权限、先读后改、落盘
- 模型:读结果里的标记;被拒后决定要不要带理由申请放宽,被用户拒了就停下解释
- 用户:选预设,批或拒每一次放宽
实跑原件
沙箱组合真跑的完整输出(sandbox.mts)
## confine(["bash","-c","echo hi"], workspace-write)
{
"argv": [
"<landlock-run>",
"--ro",
"/",
"--rw",
"/dev/null",
"--rw",
"/tmp",
"--rw",
"<ws>",
"--",
"bash",
"-c",
"echo hi"
],
"enforcement": "partial",
"denialSignatures": [
"permission denied"
],
"runnerFailureRules": [
{
"allowedExitCodes": [
125
],
"fatalSignatures": [
"landlock-run: "
],
"informationalLines": [
"landlock-run: partial enforcement (older Landlock ABI)"
]
}
]
}
## 工作区内写:echo inside > inside.txt && cat inside.txt
{
"exitCode": 0,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\n",
"sandbox": {
"mode": "workspace-write",
"denied": false,
"enforcement": "partial"
},
"modelSees": "inside\n[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n"
}
## 工作区外写($HOME):echo outside > $HOME/dsh-landlock-outside.txt
{
"exitCode": 1,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\nbash: line 1: $HOME/dsh-landlock-outside.txt: Permission denied\n",
"sandbox": {
"mode": "workspace-write",
"denied": true,
"enforcement": "partial"
},
"modelSees": "[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\nbash: line 1: $HOME/dsh-landlock-outside.txt: Permission denied\n[sandbox: file access denied under workspace-write mode]\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]\n[exit code: 1]"
}
## 读不受限:wc -l < /etc/hosts
{
"exitCode": 0,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\n",
"sandbox": {
"mode": "workspace-write",
"denied": false,
"enforcement": "partial"
},
"modelSees": "12\n[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n"
}
## 命令自己失败:false:false
{
"exitCode": 1,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\n",
"sandbox": {
"mode": "workspace-write",
"denied": false,
"enforcement": "partial"
},
"modelSees": "[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"
}
## 命令自己失败:grep 没匹配(同 ripgrep 无匹配退出 1):grep -q no-such-text /etc/hosts
{
"exitCode": 1,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\n",
"sandbox": {
"mode": "workspace-write",
"denied": false,
"enforcement": "partial"
},
"modelSees": "[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"
}
## read-only 下连工作区也不能写:echo x > ro.txt
{
"exitCode": 1,
"stderr": "landlock-run: partial enforcement (older Landlock ABI)\nbash: line 1: ro.txt: Permission denied\n",
"sandbox": {
"mode": "read-only",
"denied": true,
"enforcement": "partial"
},
"modelSees": "[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\nbash: line 1: ro.txt: Permission denied\n[sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]\n[exit code: 1]"
}
## $HOME 下的文件被写出来了吗
false
## classifyRunnerFailure:真规则 vs 只认前缀
[
{
"input": "子进程 false:提示行 + 退出 1",
"real": "(不是启动器失败)",
"prefixOnly": {
"detail": "landlock-run: partial enforcement (older Landlock ABI)"
}
},
{
"input": "子进程自选退出 125:只有提示行",
"real": "(不是启动器失败)",
"prefixOnly": {
"detail": "landlock-run: partial enforcement (older Landlock ABI)"
}
},
{
"input": "启动器真坏了:退出 125 + 致命行",
"real": {
"detail": "landlock-run: landlock ruleset error: Invalid argument"
},
"prefixOnly": {
"detail": "landlock-run: landlock ruleset error: Invalid argument"
}
}
]
## 没有后端时 confine() 抛出
{
"name": "SandboxUnavailableError",
"code": "SANDBOX_UNAVAILABLE",
"message": "sandbox mode \"workspace-write\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access."
}
放宽申请九种情形的完整输出,含审计事件(escalation.mts)
## 用户批准一次
{
"result": {
"granted": "danger-full-access"
},
"audit": [
{
"type": "approval/asked",
"data": {
"id": "8d646ce3-7e6e-434e-bdac-f7696da4de0f",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "8d646ce3-7e6e-434e-bdac-f7696da4de0f",
"outcome": "allowed-once"
}
}
]
}
## 用户拒绝
{
"result": {
"thrown": "the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"
},
"audit": [
{
"type": "approval/asked",
"data": {
"id": "6aec2ee5-ca75-4c19-8b2d-212cc97ba7d1",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "6aec2ee5-ca75-4c19-8b2d-212cc97ba7d1",
"outcome": "rejected"
}
}
]
}
## 没人回答(没有界面)
{
"result": {
"thrown": "sandbox escalation to \"danger-full-access\" requires approval, but no approval channel is available"
},
"audit": [
{
"type": "approval/asked",
"data": {
"id": "15d2c28e-c06c-4b0b-8eee-a920261cbf5e",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "15d2c28e-c06c-4b0b-8eee-a920261cbf5e",
"outcome": "unavailable"
}
}
]
}
## 回答方抛错
{
"result": {
"thrown": "sandbox escalation to \"danger-full-access\" requires approval, but no approval channel is available"
},
"audit": [
{
"type": "approval/asked",
"data": {
"id": "246be3cc-17d8-4cdf-87af-e8795f70ee1c",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "246be3cc-17d8-4cdf-87af-e8795f70ee1c",
"outcome": "unavailable"
}
}
]
}
## 回答方返回词表外的值
{
"result": {
"thrown": "sandbox escalation to \"danger-full-access\" requires approval, but no approval channel is available"
},
"audit": [
{
"type": "approval/asked",
"data": {
"id": "cf240425-3b51-426e-9abb-91f7d9c94f54",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "cf240425-3b51-426e-9abb-91f7d9c94f54",
"outcome": "unavailable"
}
}
]
}
## 会话策略是 never:回答方想批也没用
{
"result": {
"thrown": "the user rejected escalating this command to \"danger-full-access\"; it stays denied, so stop and explain instead of working around it"
},
"audit": [
{
"type": "approval/policy",
"data": {
"policy": "never"
}
},
{
"type": "approval/asked",
"data": {
"id": "b8da9501-8491-4fe0-b238-863ae9376a6a",
"toolName": "bash",
"callId": "call_1",
"reason": "escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"
}
},
{
"type": "approval/decided",
"data": {
"id": "b8da9501-8491-4fe0-b238-863ae9376a6a",
"outcome": "rejected"
}
}
]
}
## 申请和当前一样的档:不问
{
"result": {
"granted": "workspace-write"
},
"audit": []
}
## 申请更窄的档:拒收
{
"result": {
"thrown": "sandbox escalation to \"read-only\" is not strictly wider than this call's current \"workspace-write\" mode"
},
"audit": []
}
## 没装审批服务
{
"result": {
"thrown": "sandbox escalation to \"danger-full-access\" requires approval, but no approval service is composed"
},
"audit": []
}
上游快照测试重放三个放宽场景(replay.sh)
✓ snapshots/acp/acp.snapshot.ts > snapshot scenarios > snapshot: fs-escalation-approved matches the expected outputs 1172ms
✓ snapshots/acp/acp.snapshot.ts > snapshot scenarios > snapshot: escalation-approved matches the expected outputs 1192ms
✓ snapshots/acp/acp.snapshot.ts > snapshot scenarios > snapshot: escalation-rejected matches the expected outputs 1195ms
Test Files 1 passed (1)
Tests 3 passed | 13 skipped (16)