feat(sandbox-policy): describe enforced file families
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
|
||||
README.md: 3258492ba80102ec96d37baa5e4989b1c396cf15
|
||||
README.zh.md: 4bb7e3b1321620413b81414d60f7b2d02df588c6
|
||||
README.md: 45349f7b0bbb6e035dd2aa6f4695735124dd9f2d
|
||||
README.zh.md: 9393d2a22aa3df310287ccb9c5e486880453a838
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call, and the model receives that same effective policy before each request.
|
||||
The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing family receives one resolved mode-and-root policy per call and registers whether the current runtime fences filesystem tools, one-shot bash commands, or terminal sessions; the model receives only those current facts before each request.
|
||||
|
||||
## Why a shared home
|
||||
|
||||
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each resolved its own `mode` + `workspaceRoot`, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both tool layers resolve policy through `ctx.sandboxPolicy`, and both enforcing backends consume that complete per-call result. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision.
|
||||
Filesystem tools, one-shot bash commands, and terminal sessions may enforce the same mode vocabulary in different combinations. If each resolved its own `mode` + `workspaceRoot`, they could drift into a split world, exactly what [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Each enforcing backend consumes the complete owner-resolved policy and contributes its model-facing family; the current section therefore does not claim that an unfenced family shares another family's restrictions. The [cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -17,7 +17,8 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
|
||||
|
||||
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.
|
||||
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
|
||||
- `sandbox:policy` — a request-time system-prompt section derived from `resolve({ session })`. It states the current file-effect mode, its consequences, and every canonical writable root under `workspace-write`; it does not claim host permissions, sandbox-backend readiness, or network/process restrictions.
|
||||
- `ctx.sandboxPolicy.registerEnforcedFamily(family)` — independently registers `filesystem`, `bash`, or `terminal` and returns the exact effect disposer. Equal families remain separate contributions; the section uses canonical family order and removes a family only after its final contribution leaves.
|
||||
- `sandbox:policy` — a request-time system-prompt section derived from `resolve({ session })` and the active family contributions. It is empty without an enforcing family and states only the mode, the affected model-facing operations, and the canonical session workspace under `workspace-write`.
|
||||
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
|
||||
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
|
||||
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
|
||||
@@ -34,29 +35,29 @@ A runtime switch is one log-only `sandbox/mode` event on the session it applies
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One `sandbox:policy` system section on every agent request. The section states only DSH file-effect policy; tool schemas remain their owners' surfaces, approval policy remains `dsh-user-approval`'s section, and plan guidance remains `dsh-plan-mode`'s section.
|
||||
One `sandbox:policy` system section on each agent request when at least one enforcing family is registered. The examples below show all three families; absent families are omitted. Tool plugins retain operation and escalation guidance, approval policy remains `dsh-user-approval`'s section, and plan guidance remains `dsh-plan-mode`'s section.
|
||||
|
||||
##### Read-only
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: read-only. Ordinary file writes, edits, and file-mutating shell effects are denied; required sinks such as `/dev/null` may remain writable. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.
|
||||
```
|
||||
|
||||
##### Workspace-write
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: workspace-write. File writes, edits, and file-mutating shell effects are limited to these canonical writable roots: "<workspace root>", "<temporary root>". Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.
|
||||
Current DSH file policy: workspace-write. The write and edit tools, one-shot bash commands, and terminal sessions may modify files under the session workspace: "<workspace root>". Some platform temporary areas may also be writable.
|
||||
```
|
||||
|
||||
##### Danger-full-access
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: danger-full-access. The DSH file sandbox does not restrict file operations. Host OS permissions and other policies still apply. This policy does not govern network or process access.
|
||||
Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict the write and edit tools, one-shot bash commands, or terminal sessions.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
One concise system section per request. `workspace-write` additionally lists the canonical session workspace root plus the canonical `/tmp` and platform temporary roots, deduplicated when they identify the same directory.
|
||||
One concise system section per request. `workspace-write` carries only the canonical session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -66,3 +67,4 @@ The request prefix is byte-stable while the session mode and immutable workspace
|
||||
|
||||
- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`.
|
||||
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.
|
||||
- **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 standing section.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每个强制执行策略的能力家族在每次调用时都会收到一项解析完成的模式与根目录策略,模型也会在每次请求前收到同一项有效策略。
|
||||
沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每个强制执行家族在每次调用时都会收到一项解析完成的模式与根目录策略,并登记当前运行时对文件系统工具、一次性 bash 命令和终端会话中的哪些家族施加围栏;模型在每次请求前只会收到这些当前事实。
|
||||
|
||||
## 为何需要共享归属位置
|
||||
|
||||
两个家族强制执行同一套模式词汇:沙箱化 bash 执行器(`@deepseek-ai/dsh-bash-sandbox`)与沙箱化文件系统提供方(`@deepseek-ai/dsh-fs-sandbox`)。如果两者各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界:bash 限制在一个根目录,fs 却隔离另一个根目录,正是[沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。两个工具层都通过 `ctx.sandboxPolicy` 解析策略,两个强制执行后端也都消费完整的逐调用结果。[跨家族 fs 沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。
|
||||
文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,并贡献其面向模型的家族;因此,当前段落不会声称不受围栏约束的家族也受另一家族的限制。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
|
||||
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。
|
||||
- `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根目录。
|
||||
- `sandbox:policy`:由 `resolve({ session })` 派生的请求时系统提示词段落。它说明当前文件操作模式及其后果,并列出 `workspace-write` 下所有规范化的可写根目录;不会声称主机权限、沙箱后端就绪状态或网络/进程限制。
|
||||
- `ctx.sandboxPolicy.registerEnforcedFamily(family)`:独立注册 `filesystem`、`bash` 或 `terminal`,并返回对应的精确 effect disposer。相同家族仍是彼此独立的贡献;该段落使用规范的家族顺序,并且只有最后一项贡献离开后才移除对应家族。
|
||||
- `sandbox:policy`:由 `resolve({ session })` 和当前家族贡献派生的请求时系统提示词段落。没有强制执行家族时为空,只说明模式、受影响的面向模型操作,以及 `workspace-write` 下规范化的会话工作区。
|
||||
- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。
|
||||
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
|
||||
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
|
||||
@@ -34,29 +35,29 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次 agent 请求都有一个 `sandbox:policy` 系统段落。该段落只说明 DSH 文件操作策略;工具 schema 仍由各自归属方管理,批准策略仍由 `dsh-user-approval` 的段落管理,计划引导仍由 `dsh-plan-mode` 的段落管理。
|
||||
只要至少注册了一个强制执行家族,每次 agent 请求就会有一个 `sandbox:policy` 系统段落。以下示例展示全部三个家族;缺失的家族会被省略。工具插件继续负责操作与升级引导,批准策略仍由 `dsh-user-approval` 的段落管理,计划引导仍由 `dsh-plan-mode` 的段落管理。
|
||||
|
||||
##### 只读
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: read-only. Ordinary file writes, edits, and file-mutating shell effects are denied; required sinks such as `/dev/null` may remain writable. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.
|
||||
```
|
||||
|
||||
##### 工作区写入
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: workspace-write. File writes, edits, and file-mutating shell effects are limited to these canonical writable roots: "<workspace root>", "<temporary root>". Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.
|
||||
Current DSH file policy: workspace-write. The write and edit tools, one-shot bash commands, and terminal sessions may modify files under the session workspace: "<workspace root>". Some platform temporary areas may also be writable.
|
||||
```
|
||||
|
||||
##### 完全访问
|
||||
|
||||
```markdown
|
||||
Current DSH file sandbox policy: danger-full-access. The DSH file sandbox does not restrict file operations. Host OS permissions and other policies still apply. This policy does not govern network or process access.
|
||||
Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict the write and edit tools, one-shot bash commands, or terminal sessions.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个请求增加一个简洁的系统段落。`workspace-write` 还会列出规范化的会话工作区根目录,以及规范化的 `/tmp` 与平台临时根目录;如果它们指向同一目录则去重。
|
||||
每个请求增加一个简洁的系统段落。`workspace-write` 只携带规范化的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -66,3 +67,4 @@ Current DSH file sandbox policy: danger-full-access. The DSH file sandbox does n
|
||||
|
||||
- **每个会话只有一个主要工作区根目录**:策略解析 `SessionHeader.cwd`;额外可写根目录不属于 `SandboxExecutionPolicy`。
|
||||
- **仅限文件操作模式**:`SandboxMode` 管控文件操作;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。
|
||||
- **有意概述临时区域**:强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在常驻段落中如实枚举。
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* `sandbox:policy` system section; request headers therefore reconstruct the
|
||||
* same mode and roots the enforcing consumers resolve.
|
||||
*
|
||||
* Both enforcing capability families read the SAME policy here: the sandboxed
|
||||
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
|
||||
* provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call
|
||||
* policy, so bash and fs can never confine to different roots — the split
|
||||
* world the sandbox RFC warns about. The service reads session state once at
|
||||
* the tool boundary; executors and providers remain session-free.
|
||||
* Enforcing filesystem, one-shot bash, and terminal backends read the SAME
|
||||
* resolved policy here and register their independently disposable model-facing
|
||||
* families. The request section therefore describes only operations this
|
||||
* runtime actually fences, while each backend retains its own enforcement
|
||||
* dialect. The service reads session state once at each operation boundary;
|
||||
* executors and providers remain session-free.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-policy
|
||||
*/
|
||||
@@ -21,7 +21,7 @@ import { resolve as resolvePath } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalPath, writableRoots, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { effectiveSandboxMode } from './session-mode.ts'
|
||||
@@ -33,15 +33,40 @@ function resolveWorkspaceRoot(path: string): string {
|
||||
return resolvePath(canonicalPath(path))
|
||||
}
|
||||
|
||||
/** Render the current file-effect policy without claiming host or backend capabilities. */
|
||||
function renderPolicyContext(policy: SandboxExecutionPolicy): string {
|
||||
/** Model-facing operation family whose current file policy is enforced by a runtime contribution. */
|
||||
type FilePolicyFamily = 'filesystem' | 'bash' | 'terminal'
|
||||
|
||||
/** Canonical model-facing order, independent of plugin load order. */
|
||||
const FILE_POLICY_FAMILIES: readonly FilePolicyFamily[] = ['filesystem', 'bash', 'terminal']
|
||||
|
||||
const FAMILY_LABELS: Readonly<Record<FilePolicyFamily, string>> = {
|
||||
filesystem: 'the write and edit tools',
|
||||
bash: 'one-shot bash commands',
|
||||
terminal: 'terminal sessions',
|
||||
}
|
||||
|
||||
/** Join model-facing family names with stable English punctuation. */
|
||||
function familyList(families: readonly FilePolicyFamily[], conjunction: 'and' | 'or'): string {
|
||||
const labels = families.map(family => FAMILY_LABELS[family])
|
||||
if (labels.length === 1) return labels[0] as string
|
||||
if (labels.length === 2) return `${labels[0]} ${conjunction} ${labels[1]}`
|
||||
return `${labels.slice(0, -1).join(', ')}, ${conjunction} ${labels.at(-1)}`
|
||||
}
|
||||
|
||||
/** Render only policy facts shared by every backend enforcing each registered family. */
|
||||
function renderPolicyContext(policy: SandboxExecutionPolicy, families: readonly FilePolicyFamily[]): string {
|
||||
if (families.length === 0) return ''
|
||||
switch (policy.mode) {
|
||||
case 'read-only':
|
||||
return 'Current DSH file sandbox policy: read-only. Ordinary file writes, edits, and file-mutating shell effects are denied; required sinks such as `/dev/null` may remain writable. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.'
|
||||
case 'workspace-write':
|
||||
return `Current DSH file sandbox policy: workspace-write. File writes, edits, and file-mutating shell effects are limited to these canonical writable roots: ${writableRoots(policy).map(root => JSON.stringify(root)).join(', ')}. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.`
|
||||
case 'read-only': {
|
||||
const subjects = familyList(families, 'and')
|
||||
return `Current DSH file policy: read-only. ${subjects[0]?.toUpperCase()}${subjects.slice(1)} cannot modify files under this policy.`
|
||||
}
|
||||
case 'workspace-write': {
|
||||
const subjects = familyList(families, 'and')
|
||||
return `Current DSH file policy: workspace-write. ${subjects[0]?.toUpperCase()}${subjects.slice(1)} 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 sandbox policy: danger-full-access. The DSH file sandbox does not restrict file operations. Host OS permissions and other policies still apply. This policy does not govern network or process access.'
|
||||
return `Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict ${familyList(families, 'or')}.`
|
||||
/* 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
|
||||
@@ -83,9 +108,10 @@ export interface SandboxPolicyRequest {
|
||||
|
||||
/**
|
||||
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
|
||||
* default mode, fallback workspace root, and current request-time policy
|
||||
* section. Tool layers call {@link resolve} for each execution so a session's
|
||||
* mode log and immutable cwd travel together to every enforcing capability.
|
||||
* default mode, fallback workspace root, enforcing-family contributions, and
|
||||
* current request-time policy section. Tool layers call {@link resolve} for
|
||||
* each execution so a session's mode log and immutable cwd travel together to
|
||||
* every enforcing capability.
|
||||
*/
|
||||
export class SandboxPolicyService extends Service {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
@@ -100,6 +126,8 @@ export class SandboxPolicyService extends Service {
|
||||
readonly defaultMode: SandboxMode
|
||||
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
|
||||
readonly workspaceRoot: string
|
||||
/** Independently disposable enforcement-family contributions. */
|
||||
private readonly enforcedFamilies = new Map<FilePolicyFamily, Set<symbol>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'sandboxPolicy')
|
||||
@@ -115,12 +143,38 @@ export class SandboxPolicyService extends Service {
|
||||
order: 110,
|
||||
text: (context) => {
|
||||
const session = context.agent?.session
|
||||
return session === undefined ? '' : renderPolicyContext(this.resolve({ session }))
|
||||
return session === undefined ? '' : renderPolicyContext(this.resolve({ session }), this.activeFamilies())
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one runtime contribution that enforces the shared file policy for
|
||||
* a model-facing operation family. Equal families remain independently
|
||||
* disposable; registration and removal invalidate assembled prompt caches
|
||||
* when a system-prompt service is active.
|
||||
* @param family - operation family whose file effects this contribution enforces.
|
||||
* @returns the exact Cordis effect disposer for this contribution.
|
||||
*/
|
||||
registerEnforcedFamily(family: 'filesystem' | 'bash' | 'terminal'): () => void {
|
||||
const token = Symbol(family)
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const contributions = this.enforcedFamilies.get(family) ?? new Set<symbol>()
|
||||
contributions.add(token)
|
||||
this.enforcedFamilies.set(family, contributions)
|
||||
this.emitPromptChange()
|
||||
return () => {
|
||||
contributions.delete(token)
|
||||
if (contributions.size === 0 && this.enforcedFamilies.get(family) === contributions) {
|
||||
this.enforcedFamilies.delete(family)
|
||||
}
|
||||
this.emitPromptChange()
|
||||
}
|
||||
}, 'sandboxPolicy.registerEnforcedFamily()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the complete policy for one capability call. An approved explicit
|
||||
* mode outranks the session's last `sandbox/mode` event, which outranks the
|
||||
@@ -146,6 +200,16 @@ export class SandboxPolicyService extends Service {
|
||||
overrideOf(session: Session): SandboxMode | undefined {
|
||||
return effectiveSandboxMode(session.events)
|
||||
}
|
||||
|
||||
/** Active families in canonical model-facing order. */
|
||||
private activeFamilies(): FilePolicyFamily[] {
|
||||
return FILE_POLICY_FAMILIES.filter(family => (this.enforcedFamilies.get(family)?.size ?? 0) > 0)
|
||||
}
|
||||
|
||||
/** Notify prompt consumers only after their registry exists. */
|
||||
private emitPromptChange(): void {
|
||||
if (this.ctx.get('systemPrompt') !== undefined) this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxPolicyService
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* `effective = fold(events) ?? the deployment default`, so an override
|
||||
* survives restart by replay, two sessions can never see each other's state,
|
||||
* and there is no external config store. The event is log-only (the
|
||||
* `approval/*` precedent): the model learns the mode from the boundary
|
||||
* markers in the enforcing tools, never from the event itself. EXECUTION
|
||||
* honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode
|
||||
* together with the calling session's workspace root onto each capability
|
||||
* call, weakest-precedence beneath an escalation grant.
|
||||
* `approval/*` precedent): the policy owner projects the fold into each model
|
||||
* request, while enforcing tools report operation-specific boundary markers.
|
||||
* EXECUTION honors the same fold through `ctx.sandboxPolicy.resolve()` — it
|
||||
* stamps the mode together with the calling session's workspace root onto each
|
||||
* capability call, weakest-precedence beneath an escalation grant.
|
||||
*
|
||||
* The override is policy state shared by every enforcing family (bash and
|
||||
* filesystem alike), so it lives here in the policy package rather than in any
|
||||
|
||||
@@ -10,10 +10,9 @@ import { join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -130,6 +129,7 @@ describe('SandboxPolicyService', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const fiber = await ctx.plugin(SandboxPolicyService, {})
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
expect(ctx.sandboxPolicy).toBeDefined()
|
||||
expect(await policySection(ctx, session('sess-hmr'))).toContain('read-only')
|
||||
await fiber.dispose()
|
||||
@@ -146,38 +146,95 @@ describe('sandbox:policy request context', () => {
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('states the fresh read-only consequences before a tool attempt', async () => {
|
||||
it('omits policy prose when no enforcing family is registered', async () => {
|
||||
const ctx = await promptMounted()
|
||||
const text = await policySection(ctx, session('sess-read-only', '/projects/read-only'))
|
||||
expect(text).toBe('Current DSH file sandbox policy: read-only. Ordinary file writes, edits, and file-mutating shell effects are denied; required sinks such as `/dev/null` may remain writable. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.')
|
||||
expect(await policySection(ctx, session('sess-no-family'))).toBe('')
|
||||
})
|
||||
|
||||
it('states canonical workspace and temporary roots under workspace-write', async () => {
|
||||
it.each([
|
||||
[['filesystem'], 'Current DSH file policy: read-only. The write and edit tools cannot modify files under this policy.'],
|
||||
[['bash'], 'Current DSH file policy: read-only. One-shot bash commands cannot modify files under this policy.'],
|
||||
[['terminal'], 'Current DSH file policy: read-only. Terminal sessions cannot modify files under this policy.'],
|
||||
[['filesystem', 'bash'], 'Current DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files under this policy.'],
|
||||
[['filesystem', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools and terminal sessions cannot modify files under this policy.'],
|
||||
[['bash', 'terminal'], 'Current DSH file policy: read-only. One-shot bash commands and terminal sessions cannot modify files under this policy.'],
|
||||
[['filesystem', 'bash', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.'],
|
||||
] as const)('states read-only consequences for %j', async (families, expected) => {
|
||||
const ctx = await promptMounted()
|
||||
for (const family of [...families].reverse()) ctx.sandboxPolicy.registerEnforcedFamily(family)
|
||||
expect(await policySection(ctx, session(`sess-read-only-${families.join('-')}`))).toBe(expected)
|
||||
})
|
||||
|
||||
it('states the portable workspace guarantee without enumerating host temp paths', async () => {
|
||||
const ctx = await promptMounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('terminal')
|
||||
const active = session('sess-workspace-write', '/projects/../projects/current')
|
||||
const policy = ctx.sandboxPolicy.resolve({ session: active })
|
||||
const roots = writableRoots(policy)
|
||||
const text = await policySection(ctx, active)
|
||||
expect(text).toBe(`Current DSH file sandbox policy: workspace-write. File writes, edits, and file-mutating shell effects are limited to these canonical writable roots: ${roots.map(root => JSON.stringify(root)).join(', ')}. Host OS permissions and sandbox-backend availability may restrict operations further. This policy does not govern network or process access.`)
|
||||
expect(roots[0]).toBe(resolve('/projects/current'))
|
||||
expect(roots).toContain(canonicalPath('/tmp'))
|
||||
expect(await policySection(ctx, active)).toBe('Current DSH file policy: workspace-write. The write and edit tools, one-shot bash commands, and terminal sessions may modify files under the session workspace: "/projects/current". Some platform temporary areas may also be writable.')
|
||||
})
|
||||
|
||||
it('states that danger-full-access adds no DSH file restriction without claiming wider authority', async () => {
|
||||
it('states the exact families bypassed by danger-full-access', async () => {
|
||||
const ctx = await promptMounted({ mode: 'danger-full-access' })
|
||||
const text = await policySection(ctx, session('sess-danger', '/projects/current'))
|
||||
expect(text).toBe('Current DSH file sandbox policy: danger-full-access. The DSH file sandbox does not restrict file operations. Host OS permissions and other policies still apply. This policy does not govern network or process access.')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('terminal')
|
||||
expect(await policySection(ctx, session('sess-danger', '/projects/current'))).toBe('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict the write and edit tools or terminal sessions.')
|
||||
})
|
||||
|
||||
it('renders family contributions independently across mount and repeated disposal', async () => {
|
||||
const ctx = await promptMounted()
|
||||
const active = session('sess-family-lifecycle')
|
||||
const filesystemFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
}, { inject: ['sandboxPolicy'] }))
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
|
||||
let disposeBashFirst!: () => void
|
||||
const bashFirstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
disposeBashFirst = inner.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
}, { inject: ['sandboxPolicy'] }))
|
||||
const bashSecondFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
}, { inject: ['sandboxPolicy'] }))
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
disposeBashFirst()
|
||||
disposeBashFirst()
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
await bashSecondFiber.dispose()
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
await bashFirstFiber.dispose()
|
||||
await filesystemFiber.dispose()
|
||||
expect(await policySection(ctx, active)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the complete rendered prompt byte-stable across TMPDIR changes', async () => {
|
||||
const ctx = await promptMounted({ mode: 'workspace-write' })
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
const active = session('sess-tmpdir-stability', '/projects/current')
|
||||
const previous = process.env.TMPDIR
|
||||
try {
|
||||
process.env.TMPDIR = '/tmp/first-host-temp'
|
||||
const first = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentFor(active) }))
|
||||
process.env.TMPDIR = '/tmp/second-host-temp'
|
||||
const second = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentFor(active) }))
|
||||
expect(second).toBe(first)
|
||||
expect(second).not.toContain('host-temp')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TMPDIR
|
||||
else process.env.TMPDIR = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('reflects the latest durable switch on the next assembly and stays byte-stable otherwise', async () => {
|
||||
const ctx = await promptMounted()
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
const active = session('sess-switch', '/projects/current')
|
||||
const first = await policySection(ctx, active)
|
||||
expect(await policySection(ctx, active)).toBe(first)
|
||||
|
||||
setSandboxMode(active, 'danger-full-access')
|
||||
const danger = await policySection(ctx, active)
|
||||
expect(danger).toContain('does not restrict file operations')
|
||||
expect(danger).toContain('does not restrict the write and edit tools')
|
||||
expect(await policySection(ctx, active)).toBe(danger)
|
||||
|
||||
setSandboxMode(active, 'workspace-write')
|
||||
@@ -189,6 +246,7 @@ describe('sandbox:policy request context', () => {
|
||||
setSandboxMode(active, 'workspace-write')
|
||||
const resumed = new Session(active.id, active.events, active.header)
|
||||
const ctx = await promptMounted({ mode: 'read-only' })
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
|
||||
expect(await policySection(ctx, resumed)).toContain('workspace-write')
|
||||
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'sandbox:policy')?.text).toBe('')
|
||||
|
||||
Reference in New Issue
Block a user