Replies: 3 comments
|
完整改动如下(对 diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts
--- a/packages/sandbox/sandbox/src/roots.ts
+++ b/packages/sandbox/sandbox/src/roots.ts
@@ -43,13 +43,16 @@
/**
* The roots one confined execution may WRITE under — the mode's meaning as a
* canonical, deduplicated allow-list. `read-only` allows nothing;
- * `workspace-write` allows the policy's workspace root, the host `/tmp`, and
- * the per-user platform temp dir (`os.tmpdir()` — the real temp area for
- * mkstemp-family tools; omitting it would deny what the mode promises).
+ * `workspace-write` allows the policy's workspace root, the host `/tmp`, the
+ * per-user platform temp dir (`os.tmpdir()` — the real temp area for
+ * mkstemp-family tools; omitting it would deny what the mode promises), and
+ * the deployment's `policy.additionalWritableRoots` — shared toolchain caches
+ * that live outside the session workspace and whose absence from this list is
+ * what makes an in-session `go build` fail with EROFS.
* @param policy - the file-effect policy to derive the allow-list from.
* @returns the canonical writable roots; empty exactly under `read-only`.
*/
export function writableRoots(policy: SandboxExecutionPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
- return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
+ return [...new Set([policy.workspaceRoot, '/tmp', tmpdir(), ...(policy.additionalWritableRoots ?? [])].map(canonicalPath))]
}
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -42,6 +42,16 @@
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
/**
+ * Additional absolute roots a `workspace-write` execution may write under,
+ * for deployment-owned caches that live OUTSIDE the session workspace: the
+ * shared toolchain stores (a Go build/module cache, an npm or uv store) a
+ * project's build commands must write to. Canonicalized and deduplicated
+ * together with the mode's own roots by {@link writableRoots}; ignored under
+ * `read-only` and `danger-full-access`. Absent means "the mode's own roots
+ * only" — a widening the deployment opts into explicitly.
+ */
+ additionalWritableRoots?: readonly string[]
+ /**
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts
--- a/packages/sandbox/sandbox-local/src/profiles.ts
+++ b/packages/sandbox/sandbox-local/src/profiles.ts
@@ -9,7 +9,10 @@
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
/**
- * Build the bwrap profile arguments for one file-effect policy.
+ * Build the bwrap profile arguments for one file-effect policy. The policy's
+ * `additionalWritableRoots` are bound with `--bind-try` rather than `--bind`:
+ * a shared cache that is missing at run time must degrade that one directory to
+ * read-only, never fail every confined command.
* @param policy - file-effect policy to express as bwrap mounts.
* @returns profile arguments before the trailing separator and command argv.
*/
@@ -18,19 +21,21 @@
if (policy.mode === 'workspace-write') {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
+ for (const root of policy.additionalWritableRoots ?? []) args.push('--bind-try', root, root)
}
return args
}
/**
- * Build the Landlock launcher grants for one file-effect policy.
+ * Build the Landlock launcher grants for one file-effect policy, including the
+ * policy's `additionalWritableRoots` so the two Linux rungs grant the same set.
* @param policy - file-effect policy to express as Landlock allow-list grants.
* @returns launcher grant arguments before the trailing separator and command argv.
*/
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
const readWrite = ['/dev/null']
if (policy.mode === 'workspace-write') {
- readWrite.push('/tmp', policy.workspaceRoot)
+ readWrite.push('/tmp', policy.workspaceRoot, ...(policy.additionalWritableRoots ?? []))
}
return landlockGrantArgs({ readOnly: ['/'], readWrite })
}
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -20,6 +20,7 @@
* @module @deepseek-ai/dsh-sandbox-policy
*/
+import { existsSync } from 'node:fs'
import { resolve as resolvePath } from 'node:path'
import { Context, Service } from '@deepseek-ai/cordis'
import { z as zod } from 'zod'
@@ -42,8 +43,10 @@
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 'workspace-write': {
+ const extra = policy.additionalWritableRoots ?? []
+ 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.${extra.length === 0 ? '' : ` Additional shared writable roots are granted: ${extra.map(root => JSON.stringify(root)).join(', ')}.`}`
+ }
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. */
@@ -75,6 +78,16 @@
* `process.cwd()`). Normal agent calls use their session cwd instead.
*/
workspaceRoot?: string
+ /**
+ * Absolute roots outside the session workspace that every `workspace-write`
+ * execution may ALSO write under, for deployment-owned shared caches: the
+ * toolchain stores a build command writes to (a Go build/module cache, an npm
+ * or uv store) whose absence from the writable set makes in-session builds
+ * fail with EROFS. Canonicalized once here; a root that no longer exists is
+ * dropped at resolve time so a removed cache directory cannot fail a launch.
+ * Honored only under `workspace-write`. Default: none.
+ */
+ additionalWritableRoots?: readonly string[]
}
/** Inputs that select the sandbox policy for one capability call. */
@@ -113,6 +126,7 @@
// No schema default: process.cwd() is resolved in the constructor so the
// stored root is always absolute regardless of how it was supplied.
workspaceRoot: z.string(),
+ additionalWritableRoots: z.array(z.string()).default([]),
})
static inject = ['sessionProjections']
@@ -121,6 +135,8 @@
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
+ /** Canonical deployment-configured roots granted alongside the `workspace-write` root. */
+ private readonly additionalWritableRoots: readonly string[]
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
// schemastery (static Config) already filled `mode`; the cast records that
@@ -128,6 +144,7 @@
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
+ this.additionalWritableRoots = (config.additionalWritableRoots ?? []).map(canonicalPath)
ctx.sessionProjections.register({
key: 'sandboxMode',
@@ -158,13 +175,15 @@
* 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.
+ * @returns the fully resolved per-call mode, absolute workspace root, and the
+ * additional roots that still exist on disk.
*/
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),
+ additionalWritableRoots: this.additionalWritableRoots.filter(root => existsSync(root)),
...session === undefined ? {} : { sessionId: session.id },
}
}复现方式:把下列四个文件下载到对应路径后施加上述改动,
|
本机验证针对发行产物(即本部署真正运行的环境),在重载服务之后执行验证:
实测收益同一个 37 包 Go 项目执行
建议补充的测试用例沿用既有目录布局:
关于快照测试:由于 schema 默认是 本机部署现状说明在合入之前,我们已在自己的机器上按同一 diff 给发行产物打补丁运行(版本锁定 + 锚点校验,拒绝在锚点位移时写入,原件留备份可回滚),放行清单是数据文件,扩展只需改一行。也就是说这份提案来自一个已经在跑的部署,而不是设想。 |
|
A Windows-side data point for this proposal: the windows-acl rung does not need How it works
This is a deployment-owned grant in the same spirit as VerificationOn Windows 11, session under After granting
The trap worth flaggingThe SID string must match
So the two approaches are not equivalent. Example implementationFull script, including a test that asserts byte-parity against DSH's own Grant management mutates DACLs, which inside the sandbox would itself be an escape, so the commands refuse to run in a restricted process. Still in favor of this landing upstream — I am offering the above as evidence that the Windows rung can express extra writable roots, not as a reason to skip it. |
Uh oh!
There was an error while loading. Please reload this page.
@deepseek-ai/dsh0.1.5-rc.2(npm next)danger-full-access。但会话内每次构建都要全量重编症状
workspace-write允许写入的目录是固定的三个来源:会话工作区(session cwd)、/tmp、os.tmpdir()(后两者常常是同一个目录,会被去重),而且没有任何配置项可以追加。当工具链缓存被部署在工作区之外时——本部署把它们统一放在全局前缀/zdata/Envs下(Go 构建缓存 2.5G、模块缓存 2.0G,另有 npm/pnpm/uv 的存储)——会话内第一次写缓存就失败:把缓存改到
/tmp不是解法:dsh 的 bash 通道由 bubblewrap 包裹,其 profile 对/tmp挂的是“每条命令都会重建一次”的 tmpfs(--tmpfs /tmp)。放在那里的缓存,每次工具调用都从头重建——实测同一次会话里,前一条命令写下的文件在下一条命令已经不存在。它不报错,只是每次都全量重编,比报错更贵。代价可量化:同一个 37 个 Go 包的仓库执行
go vet ./...,空缓存耗时 144 秒、写出 584MB,而预热好的共享缓存只要 8 秒。空缓存正是当前策略强制出来的结果——共享的那份写不进去。(已核对上游
master:SandboxExecutionPolicy只有mode/workspaceRoot/sessionId,SandboxPolicyService.Config只有mode/workspaceRoot,尚无此能力。)根因
可写白名单是代码常量,没有任何配置面:
workspaceRoot取会话 cwd(sandbox-policy的resolve()用session?.header.cwd),所以位于/mnt/d/...的项目永远覆盖不到/zdata/...的全局缓存——除/之外没有共同祖先。两个 Linux rung 各自声明自己的授权,且都没有复用白名单 helper:
/tmp上的 per-command tmpfs,使“把缓存指到 /tmp”这个绕行方案反而有害。已排除的假设
写这份报告前已逐条核对,排除了报错文本相同的其它故障类别:
drwxrwxr-x ddz;同一写入在沙盒外成功,打补丁后在沙盒内对被授予的根也成功/zdata以只读挂载rw。只读来自执行层自己--ro-bind / /的重新挂载,EROFS 只可能来自那个 bindGOCACHE未设或指错devenv.sh无条件 export 它,且会话内go env GOCACHE正是/zdata/Envs/go/build-cacheSANDBOX_UNAVAILABLE类)Read-only file system,模型不会把它读成“应该提权到 danger-full-access”最小复现
建议方案
给沙盒策略增加一个部署级字段
additionalWritableRoots(可选,默认空数组),含义是“除工作区与临时区之外,还允许写入哪些目录”。它由决定可写集合的writableRoots()统一合并,再由各平台的 profile 构造器落实到具体执行方式(bwrap 的挂载、Landlock 的授权,以及 macOS 的 Seatbelt)。字段不设置时,行为与今天完全一致。改动落在 4 个文件:packages/sandbox/sandbox/src/index.tsSandboxExecutionPolicy.additionalWritableRoots?: readonly string[]packages/sandbox/sandbox/src/roots.tswritableRoots()合并它(自动覆盖 fs 围栏与 Seatbelt)packages/sandbox/sandbox-local/src/profiles.ts--bind-try;Landlock rung 追加到readWritepackages/sandbox/sandbox-policy/src/index.tsConfig增加该键(schema 默认[]);resolve()带上仍存在的根;模型可见的策略文本会声明它们这不是“现有代码跑错了”。
roots.ts把workspace-write的含义集中在一处,是对的设计;这里请求的是给这条语义一个部署级的扩展点,而不是改变它的含义。因此按能力提案提交,而不是缺陷报告。已否决的替代方案
/tmp/tmp上挂 per-command tmpfs:每条工具调用都会重建缓存,而且不报错danger-full-accessDSH_PERMISSION_MODE设置,还会在部署级把审批一起关掉danger-full-access+ 审批)mount --bind把共享缓存挂进工作区bubblewrap.c的BIND_RECURSIVE),工作区根之下的子挂载被一并带进来且可写。不采纳为部署方案,因为它需要 root、每个项目都要一条 fstab,并且让缓存路径脱离工具链自身的 SSOT 定义/启动)danger-full-access设计取舍与开放问题
设计取舍(每条维护者都可以推翻):
--bind-try而非--bind:对一个已被删除的目录用--bind,会让该会话后续每一条 bash 在 bwrap 启动阶段就失败;--bind-try则把影响限制为“那个目录退回只读”。工作区根仍保留普通--bind——工作区缺失是真正的硬错误。resolve()丢弃已不存在的根:避免 Landlock rung 因为一条过期授权而 fail-closed;这与 Bug report: Windows windows-acl sandbox dies permanently for a session when its cached private temp dir disappears #6483 要求的纪律一致——把缓存路径交给执行层前先复核。代价是每次 resolve 多一次statSync:若这个路径上不合适,改成在构造期过滤一次,对常见情形是等价的。roots.ts当初要消除的那种不对称(“写工具不能写 /tmp,但 bash 能写”)。additionalWritableRoots是我们的建议;extraWritableRoots/grantedRoots同样可以。字段可选、只做新增,唯一无法在事后无代价更改的就是名字。read-only与danger-full-access模式不受影响:合并逻辑位于既有的mode !== 'workspace-write'提前返回之后。writableRoots(),因此自动覆盖。windows-acl rung 用显式的--workspace/--temp开关,不在本次补丁范围内;若那里也应支持额外根,需要单独决定。开放问题(本机无法定论、如实列出):
readWrite授权、而其路径已不存在时,是否会 fail-closed?没有该原生插件的源码我们证实不了,而这个答案决定了 resolve 期过滤是必需还是仅防御性。resolve()都做(我们的选择)?两者都正确,区别只在“何时观察到某个目录已消失”。风险与兼容性
[];未设置的部署保持今天的白名单、argv 与策略文本。approveEscalation放宽的是模式而不是根。--bind-try与 resolve 期过滤正是为此存在:配置错误或目录消失,都不会演变成“bash 不能用”。请求
请评估字段命名与是否接受这一扩展。完整的 patch(4 个文件、约 +40 行,含注释)与验证结果见随后两条评论,
git apply即可试用。All reactions