@morlay/dsh-sandbox-local 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,63 +1,12 @@
1
1
  # @morlay/dsh-sandbox-local
2
2
 
3
- 可配置沙箱 bundle:替换官方 `ctx.sandbox`(进程沙箱)与 `ctx.fs`(文件系统围栏),
4
- 在官方语义之上叠加 `access` 规则——`rw <path>` 追加工作区之外的可写根,
5
- `r- <path>` 只读(读放行、写拒绝),`-- <pattern>` 拒绝访问(读与写都拒)。
3
+ 可配置沙箱与审批的**运行时策略面** bundle:替换官方 `ctx.sandbox`(进程沙箱)与 `ctx.fs`(文件系统围栏),在官方
4
+ 语义之上叠加 `access` 规则——`rw <path>` 追加工作区之外的可写根,`r- <path>` 只读(读放行、写拒绝),
5
+ `-- <pattern>` 拒绝访问(读与写都拒);并接管模型看到的运行时快照(`sandbox:policy` / `approval:policy`)。
6
6
 
7
- ## 为什么
7
+ ## 用法
8
8
 
9
- 上游沙箱策略只有两个字段:`mode`(`read-only` / `workspace-write` /
10
- `danger-full-access`)与 `workspaceRoot`;`workspace-write` 的可写路径是硬编码的
11
- `[工作区, /tmp, os.tmpdir()]`(`vendor/deepseek-harness/packages/sandbox/sandbox/src/roots.ts:52-55`),
12
- 没有任何追加可写根或拒绝项的配置面。于是「让 agent 能写 `$XDG_CACHE_HOME`,
13
- 但永远不许碰项目里的 `mise.*.toml`」这类诉求只能整块放弃隔离。
14
-
15
- 本包把「能表达多少就说多少」明确下来:Seatbelt 完整生效,其余平台按方言降级,
16
- 并在加载期告警,而不是静默失效。
17
-
18
- ## 行为
19
-
20
- 替换两个服务,规则在两个入口保持同一语义:
21
-
22
- | 条目 | `ctx.fs`(read / write / edit / list 工具) | `ctx.sandbox`(bash 等子进程) |
23
- | ----------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
24
- | `-- <path>` | 任何模式下读与写都拒(`resolve` 入口即拦截) | Seatbelt:读 + 写;bwrap:退化为只读;Landlock / Windows ACL:无表达(加载期告警) |
25
- | `r- <path>` | 任何模式下读放行、写拒绝(优先于可写根) | Seatbelt:`(deny file-write* …)`;bwrap:`--ro-bind-try`(只读挂载);Landlock / Windows:无表达 |
26
- | `rw <path>` | `workspace-write` 下计入可写根 | Seatbelt:`(allow file-write* (subpath …))`;bwrap:`--bind-try`;Landlock:`--rw`;Windows:无表达 |
27
- | 无条目 | 与官方 `fs-sandbox` 行为一致 | 与官方 argv 逐字一致(不做任何改写) |
28
-
29
- - **进程沙箱侧是复用,不是重写**:`ConfigurableSandboxProvider` 继承官方
30
- `LocalSandboxProvider`,`confine` 先走 `super.confine()`(runner 探测与选择、
31
- Windows ACL 私有 temp、拒绝方言与 runner 失败规则全部保留),再按方言把规则追加到
32
- 返回的 argv 上。
33
- - **Seatbelt 规则追加在 profile 末尾**:SBPL 的后置规则覆盖先置规则,因此
34
- `(deny file-read* file-write* …)` 能压过官方已写入的 `(allow file-write* (subpath …))`;
35
- 已用真实 `sandbox-exec` 验证(`src/__tests__/seatbelt.e2e.spec.ts`)。
36
- - **三类条目在两个入口同步**:上游把 `writableRoots` 同时喂给 Seatbelt profile 与
37
- 进程内 fs 围栏,只改一侧会造出「bash 能写、write 工具不能写」的裂缝。
38
- - **命中优先级 `--` > `r-` > `rw` / 平台可写根**:显式拒绝覆盖只读声明,只读声明覆盖
39
- 更宽的可写授予(例如 `rw {{ env.XDG_DATA_HOME }}` 与 `r- {{ env.XDG_DATA_HOME }}/secrets`
40
- 同时存在时,后者胜)。
41
- - **`r-` / `--` 条目在 `danger-full-access` 下仍然生效**(`ctx.fs` 侧):它们是显式写下的
42
- 用户规则,不是模式的推论;进程沙箱侧在 `danger-full-access` 下不经过沙箱,本包也无从施加。
43
-
44
- ## 配置
45
-
46
- 六个字段都上设置页:`access` 可改(**改完规则当场重算**,不用重挂这一行);其余五个是**装配事实**,页面上只读
47
- 可见——它们在 `Config` 上标了 `.volatile().disabled()`,所以能看清「这一行现在装配成什么样」而改不了。`volatile`
48
- 会让解析后的值变成**引用**,而官方 `LocalSandboxProvider` / `LocalFileSystem` 在构造时读一次这些字段并做装配期
49
- 校验,所以交给基类的那份配置先由 `upstreamConfigOf` 解包成值(`src/config.ts`)。
50
-
51
- | 字段 | 默认 | 含义 |
52
- | ------------------------- | --------------- | --------------------------------------------------------------------------------------------- |
53
- | `access` | `[]` | 规则条目:`rw <path>` 可写根 / `r- <path>` 只读 / `-- <pattern>` 拒绝访问;数组或一段多行文本 |
54
- | `runnerCommand` | `[]` | 透传官方 `sandbox-local`:替换 runner argv(配置了它就不能用规则) |
55
- | `runnerFailureSignatures` | `[]` | 透传官方 `sandbox-local`:自定义 runner 的失败签名 |
56
- | `probeTimeoutMs` | `5000` | 透传官方 `sandbox-local`:候选 runner 的探测超时 |
57
- | `cwd` | `process.cwd()` | 透传官方 `fs-local`:相对路径的解析基准 |
58
- | `diffBasisMaxBytes` | `10485760` | 透传官方 `fs-local`:overwrite diff 单侧字节上限 |
59
-
60
- 两种写法等价(数组每项一条,或多行文本每行一条;多行文本的空行忽略):
9
+ `access` 的两种写法等价(数组每项一条,或多行文本每行一条;空行忽略):
61
10
 
62
11
  ```yaml
63
12
  - id: sandbox-local
@@ -69,87 +18,28 @@
69
18
  - "-- **/*.pem"
70
19
  ```
71
20
 
72
- ```yaml
73
- - id: sandbox-local
74
- config:
75
- access: |-
76
- rw {{ env.XDG_CACHE_HOME }}
77
- r- {{ env.XDG_CONFIG_HOME }}
78
- -- mise.*.toml
79
- -- **/*.pem
80
- ```
81
-
82
- 条目语法:
83
-
84
- - 每条必须以 `rw ` / `r- ` / `-- ` 开头;缺前缀、或前缀后没有路径,加载即失败(规则
85
- 不因写法歧义而变形)。
86
- - 语义:`rw` 允许读写;`r-` 只允许读;`--` 读与写都拒绝。优先级 `--` > `r-` > `rw`。
87
- - `{{ env.NAME }}` 在加载期按进程环境展开;变量未设置或为空时插件加载失败。
88
- - 相对路径相对**会话工作区**(不是 `cwd` 配置项)解析。
89
- - `r-` 与 `--` 条目接受 glob:`*` 与 `?` 不跨 `/`,`**` 跨层级(`**/` 也匹配零层),
90
- `[!ab]` 取反;生成的正则同时用于进程内匹配与 SBPL 的 `(regex #"…")`,因此只用两者
91
- 共有的语法。
92
- - 字面(无通配)的 `r-` / `--` 条目命中自身**及其全部后代**;`rw` 条目必须是具体路径
93
- (可写根没有「通配」语义)。
94
-
95
- ## 装配
96
-
97
- 本包自带 `cordis.patch.yml`(禁用官方 `sandbox` / `fs-sandbox` 两行 + 插入自己的一行),
98
- 把本包作为**独立 bundle** 采用的部署直接列进 `dsh.profile.bundles` 即可;行不带 config
99
- (schema 默认是空规则),patch 内容见该文件。
21
+ - 每条必须以 `rw ` / `r- ` / `-- ` 开头;缺前缀或前缀后没有路径,加载即失败。
22
+ - 命中优先级 `--` > `r-` > `rw` / 平台可写根;`r-` / `--` 在 `danger-full-access` 下仍然生效。
23
+ - `{{ env.NAME }}` 加载期按进程环境展开(未设置或为空即失败);相对路径相对**会话工作区**解析。
24
+ - `r-` / `--` 接受 glob(`*` / `?` 不跨 `/`,`**` 跨层级,`[!ab]` 取反);`rw` 必须是具体路径。
100
25
 
101
- **本部署走的就是这条路径**:示例 app 的 `dsh.profile.bundles` 列出了本包(排在
102
- [`@morlay/dsh-profile`](../../bundles/sandbox-profile/README.md) 之前),因此"禁用官方两行 + 插入本行"由这份
103
- patch 负责;`access` 规则的值由 `dsh-profile` 按 id 做 config 覆盖——装配与配置各归一处
104
- (`@morlay/dsh-profile` 已在 `dependencies` 声明本包)。patch 层级的合并顺序与放置理由见
105
- [设计 host 层部署配置](../../bundles/sandbox-profile/README.md)。
106
- 两种采用方式互斥:同时上线会重复插入同一行。
26
+ 字段、规则语义、平台表达能力与装配链路见[设计 可配置沙箱的规则面与平台降级](./.agents/designs/20260929-可配置沙箱的规则面与平台降级.md)。
107
27
 
108
- ## 接管 `sandbox:policy`
28
+ ## 运行时快照(runtime context)
109
29
 
110
- 换了 `ctx.sandbox` / `ctx.fs`,模型看到的那段运行时策略文本也得跟着换:上游
111
- `@deepseek-ai/dsh-sandbox-policy` 在**全局层**注册 `sandbox:policy`,文本只描述官方策略(只读 /
112
- workspace-write / 全权 + workspace root),不知道本部署追加的 `rw` / `r-` / `--` 规则。
30
+ 模型每步看到的那段动态快照里,本部署替换掉两条(都在 agent 创建时按 agent 作用域注册同名 context,近的作用域遮蔽
31
+ 上游在全局层注册的那条;agentless 装配仍读上游那条):
113
32
 
114
- 全局层同名注册会抛错(`NamedEntries.insert`),上游给的官方路径是**按 agent 作用域覆盖**:本包在
115
- `system-prompt/assemble` 时为每个 agent 在它的 `ctx` 上注册同名 context,装配时近的作用域遮蔽全局那条
116
- ([`src/policy.ts`](./src/policy.ts))。文本保留官方三种 mode 的语义(重写中文),并在规则非空时追加
117
- 本部署的额外可写根 / 只读项 / 拒绝项;没有 agent 的装配(agentless 调用)保持官方那条。
33
+ | context | 上游文本 | 本包的文本 |
34
+ | ----------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
35
+ | `sandbox:policy` | 官方三种 mode 的英文描述 | 中文简化版 + 本部署的规则行(`额外可写` / `只读(不可写)` / `拒绝(读写都拒)`) |
36
+ | `approval:policy` | 英文 `Approval policy: ask…` / `Approval prompts are disabled…` | 按会话有效策略(`ctx.approval` 的 `overrideOf` 优先,否则 `config.policy`,再退 `ask`)选中文那一段 |
118
37
 
119
- **升级注意**:这段文本是照上游 `renderPolicyContext` 的语义重写的——上游改了模式集合或措辞时测试不会自动
120
- 发现,同步上游时要读一眼
121
- `vendor/deepseek-harness/packages/sandbox/sandbox-policy/src/index.ts`。
38
+ 注册时机是 agent 创建(`agent/created` + 覆盖已存在的 agent):`SystemPrompt.assemble()` 先把 `contexts` merge 好
39
+ 再进瀑布,挂在瀑布里注册只能从**第二次**装配起生效(会话里就是"先英文后中文")。
122
40
 
123
- ## 前提
124
-
125
- - 官方 `sandbox` 与 `fs-sandbox` 行必须禁用:同一 scope 内重复注册同名服务会 fail loud
126
- (`service "sandbox" has been registered at …`),而不是覆盖。
127
- - 启用规则的层必须同时做三件事——禁用官方两行、插入本包行、写规则:只做后两件时官方
128
- 实现仍在提供 `ctx.sandbox` / `ctx.fs`,规则没有生效点,沙箱静默退回「只有工作区 +
129
- `/tmp` 可写」(命令照常跑,没有报错)。装配守卫见 `@morlay/dsh-profile` 的
130
- `patch.spec.ts`。
131
- - 规则与 `runnerCommand` 互斥:自定义 runner 的 argv 方言无法识别,此时配了规则会在
132
- `confine` 抛错(宁可失败也不让规则静默失效)。
133
- - `read-only` 模式不追加 `rw` 条目(显式选定的只读边界不因额外可写根放松),但 `r-`
134
- 与 `--` 条目仍然生效。
135
-
136
- ## 已知限制
137
-
138
- - **Linux / Windows 的子进程侧降级**:bwrap 把 `r-` 与 `--` 都表达成只读挂载
139
- (`--ro-bind-try`,所以 `--` 在 bwrap 上退化为「只拒写入」),Landlock 无法表达任何
140
- 子路径规则,Windows ACL runner 的 argv 没有承载额外 grant 的入口(`rw` 同样不生效)。
141
- 加载期对每种降级都打 warn,`ctx.fs` 侧(read / write / edit 工具)在所有平台保持完整
142
- 语义。
143
- - **bwrap 参数顺序未实测**:`--bind-try` / `--ro-bind-try` 的「后挂载覆盖先挂载」与
144
- `-try` 缺路径语义来自 bwrap 文档而非本仓库的测试证据(本机为 macOS)。
145
- - **`r-` / `--` 条目不隐藏目录项**:`ls` 仍能看到被保护文件的名字,被拦的是内容读取
146
- (仅 `--`)与写入。
147
- - **`ctx.fs` 侧是策略检查,不是内核边界**:与它替换掉的官方 `fs-sandbox` 同一威胁模型
148
- (受信代码 + 模型可控路径);内核级隔离仍是 `ctx.sandbox` 的职责。
149
- - **Windows 额外授权未实现**:官方 `AclWriteGrant` 可以做预授权,但没有把 `AclWriteGrant`
150
- 接进 `confine` 的现成路径,本版只告警。
151
-
152
- ## 验证
41
+ ## 装配
153
42
 
154
- 构建、测试与 lint 走根 `justfile`(含 `seatbelt.e2e.spec.ts`——非 macOS 或被更外层 Seatbelt
155
- 拦住时自动跳过);本包的接缝与判据见 [`.agents/standards/`](./.agents/standards/)。
43
+ 行数据在本包的 `./rows` 出口(禁官方 `sandbox` / `fs-sandbox` 两行 + 插入本包一行);采用它的部署 bundle 是
44
+ [`@morlay/sandbox-profile`](../../bundles/sandbox-profile/README.md)——它渲染出这份 patch,并在同一份 patch 里按 id
45
+ 给 `sandbox-local` 的 `access` 值。部署侧只需把那个 bundle 列进 `dsh.profile.bundles`。
package/dist/client.cjs CHANGED
@@ -5,29 +5,20 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  //#region src/client/locales.ts
8
- /** 本包在配置页上的字段文案(命名空间 `settings.sandbox-local`)。 */
9
8
  const zh = {
10
9
  access: "额外可写根与拒绝项",
11
- accessHint: "一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;支持 `~`、环境变量与 glob。改完当场生效。"
10
+ accessHint: "一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;支持环境变量模板与 glob(`rw` 项必须是具体路径)。改完当场生效。"
12
11
  };
13
12
  const en = {
14
13
  access: "Extra writable roots and refusals",
15
- accessHint: "One entry per line: `rw:<path>` grants a writable root, `r-:<path>` makes it read-only, `--:<path>` denies it; `~`, env templates, and globs work. Edits apply immediately."
14
+ accessHint: "One entry per line: `rw <path>` grants a writable root, `r- <path>` makes it read-only, `-- <path>` denies it; env templates and globs work (`rw` entries must name a concrete path). Edits apply immediately."
16
15
  };
17
16
  //#endregion
18
17
  //#region src/client/index.ts
19
- /** 本包 host 行 id:行配置页读的就是这个命名空间。 */
20
18
  const SANDBOX_NS = "sandbox-local";
21
- /** 这一行里需要中文文案的字段。 */
22
19
  const FIELDS = ["access"];
23
- /** 本页字典的命名空间。 */
24
20
  const NS = "settings.sandbox-local";
25
- /** 需要的服务:槽位(字段槽)与字典。 */
26
21
  const inject = ["locale"];
27
- /**
28
- * 给 `access` 补文案。
29
- * @param ctx - 浏览器插件上下文。
30
- */
31
22
  function apply(ctx) {
32
23
  const t = ctx.locale.bind(NS);
33
24
  ctx.effect(() => ctx.locale.register(NS, {
package/dist/client.d.cts CHANGED
@@ -1,29 +1,20 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
2
  //#region src/client/locales.d.ts
3
- /** 本包在配置页上的字段文案(命名空间 `settings.sandbox-local`)。 */
4
3
  declare const zh: {
5
4
  readonly access: "额外可写根与拒绝项";
6
- readonly accessHint: "一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;支持 `~`、环境变量与 glob。改完当场生效。";
5
+ readonly accessHint: "一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;支持环境变量模板与 glob(`rw` 项必须是具体路径)。改完当场生效。";
7
6
  };
8
7
  type SandboxFieldLocaleKey = keyof typeof zh;
9
8
  //#endregion
10
9
  //#region src/client/index.d.ts
11
- /** 本包 host 行 id:行配置页读的就是这个命名空间。 */
12
10
  declare const SANDBOX_NS = "sandbox-local";
13
- /** 本页字典的命名空间。 */
14
11
  declare const NS = "settings.sandbox-local";
15
12
  declare module "@deepseek-ai/dsh-client-ui-slots" {
16
13
  interface LocaleNamespaceMap {
17
- /** 设置页里这一行的字段文案。 */
18
14
  "settings.sandbox-local": SandboxFieldLocaleKey;
19
15
  }
20
16
  }
21
- /** 需要的服务:槽位(字段槽)与字典。 */
22
17
  declare const inject: string[];
23
- /**
24
- * 给 `access` 补文案。
25
- * @param ctx - 浏览器插件上下文。
26
- */
27
18
  declare function apply(ctx: Context): void;
28
19
  //#endregion
29
20
  export { NS, SANDBOX_NS, apply, inject };
package/dist/client.d.mts CHANGED
@@ -1,29 +1,20 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
2
  //#region src/client/locales.d.ts
3
- /** 本包在配置页上的字段文案(命名空间 `settings.sandbox-local`)。 */
4
3
  declare const zh: {
5
4
  readonly access: "额外可写根与拒绝项";
6
- readonly accessHint: "一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;支持 `~`、环境变量与 glob。改完当场生效。";
5
+ readonly accessHint: "一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;支持环境变量模板与 glob(`rw` 项必须是具体路径)。改完当场生效。";
7
6
  };
8
7
  type SandboxFieldLocaleKey = keyof typeof zh;
9
8
  //#endregion
10
9
  //#region src/client/index.d.ts
11
- /** 本包 host 行 id:行配置页读的就是这个命名空间。 */
12
10
  declare const SANDBOX_NS = "sandbox-local";
13
- /** 本页字典的命名空间。 */
14
11
  declare const NS = "settings.sandbox-local";
15
12
  declare module "@deepseek-ai/dsh-client-ui-slots" {
16
13
  interface LocaleNamespaceMap {
17
- /** 设置页里这一行的字段文案。 */
18
14
  "settings.sandbox-local": SandboxFieldLocaleKey;
19
15
  }
20
16
  }
21
- /** 需要的服务:槽位(字段槽)与字典。 */
22
17
  declare const inject: string[];
23
- /**
24
- * 给 `access` 补文案。
25
- * @param ctx - 浏览器插件上下文。
26
- */
27
18
  declare function apply(ctx: Context): void;
28
19
  //#endregion
29
20
  export { NS, SANDBOX_NS, apply, inject };
package/dist/index.d.cts CHANGED
@@ -7,13 +7,6 @@ import { Config as Config$2 } from "@deepseek-ai/dsh-sandbox-local";
7
7
  interface Config extends Config$2, Config$1 {
8
8
  access?: string | string[];
9
9
  }
10
- /**
11
- * schema 解析之后的形状:`access` 是 **volatile 稳定引用**(页面可改的那一项),读它要过 `.get()`。
12
- *
13
- * 其余字段也是 volatile,但页面把它们画成**只读**(schema 上的 `disabled()`):它们是装配事实——runner 命令、
14
- * 进程 cwd、上游基类读一次的超时与差额上限——放在页面上是为了看清「这一行现在装配成什么样」,改它们要重挂
15
- * 这一行。所以给上游基类的那份配置是**解包后的值**({@link upstreamConfigOf}):基类读的是值,不是引用。
16
- */
17
10
  interface ResolvedConfig extends Omit<Config, "access" | "runnerCommand" | "runnerFailureSignatures" | "probeTimeoutMs" | "cwd" | "diffBasisMaxBytes"> {
18
11
  readonly access: Volatile<string | string[]>;
19
12
  readonly runnerCommand: Volatile<readonly string[]>;
package/dist/index.d.mts CHANGED
@@ -7,13 +7,6 @@ import { Volatile } from "@deepseek-ai/cosmokit";
7
7
  interface Config extends Config$2, Config$1 {
8
8
  access?: string | string[];
9
9
  }
10
- /**
11
- * schema 解析之后的形状:`access` 是 **volatile 稳定引用**(页面可改的那一项),读它要过 `.get()`。
12
- *
13
- * 其余字段也是 volatile,但页面把它们画成**只读**(schema 上的 `disabled()`):它们是装配事实——runner 命令、
14
- * 进程 cwd、上游基类读一次的超时与差额上限——放在页面上是为了看清「这一行现在装配成什么样」,改它们要重挂
15
- * 这一行。所以给上游基类的那份配置是**解包后的值**({@link upstreamConfigOf}):基类读的是值,不是引用。
16
- */
17
10
  interface ResolvedConfig extends Omit<Config, "access" | "runnerCommand" | "runnerFailureSignatures" | "probeTimeoutMs" | "cwd" | "diffBasisMaxBytes"> {
18
11
  readonly access: Volatile<string | string[]>;
19
12
  readonly runnerCommand: Volatile<readonly string[]>;
package/dist/index.mjs CHANGED
@@ -6,15 +6,11 @@ import { dirname, isAbsolute, resolve, sep } from "node:path";
6
6
  import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
7
7
  import { LocalSandboxProvider } from "@deepseek-ai/dsh-sandbox-local";
8
8
  //#region src/config.ts
9
- /**
10
- * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
11
- * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
12
- */
13
9
  const localized = (text) => text;
14
10
  const Config = z.object({
15
11
  access: z.union([z.array(z.string()), z.string()]).default([]).description(localized({
16
- zh: "额外可写根与拒绝项,一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;支持 `~`、环境变量与 glob。改它当场生效(沙箱规则每次按当前值重算),不用重挂这一行。",
17
- en: "Extra writable roots and refusals, one entry per line: `rw:<path>` grants a writable root, `r-:<path>` makes it read-only, `--:<path>` denies it; `~`, env templates, and globs are supported. Edits take effect on the next rule compile without remounting the row."
12
+ zh: "额外可写根与拒绝项,一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;路径可用环境变量模板与 glob(`rw` 项必须是具体路径)。改它当场生效(沙箱规则每次按当前值重算),不用重挂这一行。",
13
+ en: "Extra writable roots and refusals, one entry per line: `rw <path>` grants a writable root, `r- <path>` makes it read-only, `-- <path>` denies it; env templates and globs work (`rw` entries must name a concrete path). Edits take effect on the next rule compile without remounting the row."
18
14
  })).volatile(),
19
15
  runnerCommand: z.array(z.string()).default([]).disabled().description(localized({
20
16
  zh: "沙箱 runner 的启动命令;空数组表示按平台自带的链去选。改它要重挂这一行。",
@@ -37,14 +33,6 @@ const Config = z.object({
37
33
  en: "Per-side byte cap for overwrite diffs (10 MiB by default)."
38
34
  })).volatile()
39
35
  });
40
- /**
41
- * 交给上游基类的那份配置:装配事实取当前值。
42
- *
43
- * 上游 `LocalSandboxProvider` / `LocalFileSystem` 在构造时读一次这些字段并做装配期校验,所以它们要的是值;
44
- * 设置页改它们走的是「写盘 → Loader 重挂这一行 → 重新解析」这条路。
45
- * @param config - 本行解析后的配置。
46
- * @returns 上游两个基类认的普通配置对象。
47
- */
48
36
  function upstreamConfigOf(config) {
49
37
  return {
50
38
  runnerCommand: [...config.runnerCommand.get()],
@@ -248,7 +236,6 @@ var ConfigurableFileSystem = class extends LocalFileSystem {
248
236
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
249
237
  this.access = config.access;
250
238
  }
251
- /** 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。 */
252
239
  get source() {
253
240
  return ruleSourceOf(this.access.get(), process.env);
254
241
  }
@@ -287,7 +274,6 @@ var ConfigurableFileSystem = class extends LocalFileSystem {
287
274
  for (const root of writableRootsWith(rules, policy)) if (await isPathUnder(fresh.targetKey, root)) return fresh;
288
275
  throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
289
276
  }
290
- /** 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。 */
291
277
  rulesFor(workspaceRoot) {
292
278
  const access = this.access.get();
293
279
  const key = `${workspaceRoot}\u0000${JSON.stringify(access ?? null)}`;
@@ -300,74 +286,69 @@ var ConfigurableFileSystem = class extends LocalFileSystem {
300
286
  };
301
287
  //#endregion
302
288
  //#region src/policy.ts
303
- /**
304
- * 接管 `sandbox:policy` 那条运行时上下文。
305
- *
306
- * 上游 [`@deepseek-ai/dsh-sandbox-policy`] 在**全局层**注册它,文本只描述官方策略(只读 /
307
- * workspace-write / 全权 + workspace root)——它不知道本部署追加的 `rw` / `r-` / `--` 规则,
308
- * 模型因此拿不到"哪些额外路径可写、哪些被拒"。本包替换了 `ctx.sandbox` / `ctx.fs`,这条文本也得跟着换。
309
- *
310
- * 全局层同名注册会抛错(`NamedEntries.insert`),上游给的官方路径是**按 agent 作用域覆盖**:
311
- * `systemPrompt.context()` 在 scope 上注册同名项,装配时近的作用域遮蔽全局那条
312
- * (`ScopedLayers.merge`)。所以这里在每个 agent 的 `ctx` 上注册一次。
313
- */
314
- /** 上游注册的运行时上下文名:同名才叫接管。 */
315
289
  const SANDBOX_POLICY_CONTEXT = "sandbox:policy";
316
- /**
317
- * 策略文本:官方三种 mode 的语义(重写中文,与其它注入文案一致)+ 本部署追加的规则。
318
- * @param policy - 该会话解析出来的策略(模式与 workspace root)。
319
- * @param rules - 本部署解析后的访问规则(已展开环境变量模板)。
320
- * @returns 给模型看的一段文本。
321
- */
290
+ const APPROVAL_POLICY_CONTEXT = "approval:policy";
322
291
  function renderPolicyContext(policy, rules) {
323
292
  const base = (() => {
324
293
  switch (policy.mode) {
325
- case "read-only": return "当前 DSH 文件策略:只读。任何可用的操作都不能修改 standing 模式下的文件。别仅凭这条策略就拒绝必需的修改:照常尝试可用工具,并遵循它给出的拒绝与升级指引。";
326
- case "workspace-write": return `当前 DSH 文件策略:workspace-write(工作区可写)。任何可用的操作都可修改会话工作区 ${JSON.stringify(policy.workspaceRoot)} 下的文件;部分平台临时目录同样可写。`;
327
- case "danger-full-access": return "当前 DSH 文件策略:danger-full-access(全权)。DSH 文件沙箱不再限制可用操作对文件的修改。";
294
+ case "read-only": return "文件策略:只读——不能修改任何文件。别因此拒绝必需的修改:照常尝试工具,按它的拒绝与升级指引行动。";
295
+ case "workspace-write": return `文件策略:workspace-write——可改会话工作区 ${JSON.stringify(policy.workspaceRoot)} 下的文件,部分平台临时目录同样可写。`;
296
+ case "danger-full-access": return "文件策略:全权——文件改动不再受限。";
328
297
  }
329
298
  })();
330
299
  const extras = [];
331
- if (rules.allowWrite.length > 0) extras.push(`本部署额外授权的可写根:${rules.allowWrite.join("、")}。`);
332
- if (rules.readOnly.length > 0) extras.push(`只读(一律不可写):${rules.readOnly.join("、")}。`);
333
- if (rules.deny.length > 0) extras.push(`拒绝访问(读与写都拒):${rules.deny.join("、")}。`);
300
+ if (rules.allowWrite.length > 0) extras.push(`额外可写:${rules.allowWrite.join("、")}。`);
301
+ if (rules.readOnly.length > 0) extras.push(`只读(不可写):${rules.readOnly.join("、")}。`);
302
+ if (rules.deny.length > 0) extras.push(`拒绝(读写都拒):${rules.deny.join("、")}。`);
334
303
  return extras.length === 0 ? base : `${base} ${extras.join("")}`;
335
304
  }
336
- /**
337
- * 在给定作用域注册同名策略文本(agent 的 `ctx`,或 preset 子树)。
338
- * @param scope - 拥有这次注册的 ctx;它的 scope 因此遮蔽全局那条。
339
- * @param rules - 本部署的访问规则。
340
- * @param resolve - 按会话解析策略(生产传 `ctx.sandboxPolicy.resolve`)。
341
- */
305
+ const APPROVAL_ASK_TEXT = "审批策略:ask——需要审批的操作会询问用户;没有可用的应答者时直接失败。";
306
+ const APPROVAL_NEVER_TEXT = "审批提示已禁用:需要审批的操作一律自动拒绝——不要请求沙箱升级(不要设 sandbox_permissions)。";
307
+ function renderApprovalContext(policy) {
308
+ return policy === "never" ? APPROVAL_NEVER_TEXT : APPROVAL_ASK_TEXT;
309
+ }
310
+ function effectiveApprovalPolicy(approval, session) {
311
+ return approval.overrideOf(session) ?? approval.config.policy ?? "ask";
312
+ }
342
313
  function registerPolicyContext(scope, rules, resolve) {
343
- scope.inject(["systemPrompt"], (scoped) => {
344
- scoped.systemPrompt.context({
345
- name: SANDBOX_POLICY_CONTEXT,
346
- order: scoped.systemPrompt.getContextOrder("SANDBOX_POLICY"),
347
- text: (context) => {
348
- const session = context.agent?.session;
349
- return session === void 0 ? "" : renderPolicyContext(resolve(session), rules);
350
- }
351
- });
314
+ const prompt = scope.get("systemPrompt");
315
+ if (prompt === void 0) return;
316
+ prompt.context({
317
+ name: SANDBOX_POLICY_CONTEXT,
318
+ order: prompt.getContextOrder("SANDBOX_POLICY"),
319
+ text: (context) => {
320
+ const session = context.agent?.session;
321
+ return session === void 0 ? "" : renderPolicyContext(resolve(session), rules);
322
+ }
352
323
  });
353
324
  }
354
- /**
355
- * 每个 agent 注册一次(装配期做,与 scope 出口同一时机与理由:创建期服务可用性还在变)。
356
- * @param ctx - 本插件的 ctx(host 平面)。
357
- * @param rules - 本部署的访问规则。
358
- * @param resolve - 按会话解析策略。
359
- */
360
- function installPolicyContext(ctx, rules, resolve) {
361
- const registered = /* @__PURE__ */ new WeakSet();
362
- ctx.on("system-prompt/assemble", (_assembly, context, next) => {
363
- const agent = context.agent;
364
- if (agent !== void 0 && !registered.has(agent)) {
365
- registered.add(agent);
366
- registerPolicyContext(agent.ctx, rules, resolve);
325
+ function registerApprovalContext(scope) {
326
+ const prompt = scope.get("systemPrompt");
327
+ if (prompt === void 0) return;
328
+ prompt.context({
329
+ name: APPROVAL_POLICY_CONTEXT,
330
+ order: prompt.getContextOrder("APPROVAL_POLICY"),
331
+ text: (context) => {
332
+ const agent = context.agent;
333
+ if (agent === void 0) return "";
334
+ const approval = agent.ctx.get("approval");
335
+ return approval === void 0 ? "" : renderApprovalContext(effectiveApprovalPolicy(approval, agent.session));
367
336
  }
368
- return next();
369
337
  });
370
338
  }
339
+ function installRuntimeContexts(ctx, rules, resolve) {
340
+ const installed = /* @__PURE__ */ new WeakSet();
341
+ const install = (agent) => {
342
+ if (installed.has(agent)) return;
343
+ installed.add(agent);
344
+ registerPolicyContext(agent.ctx, rules, resolve);
345
+ registerApprovalContext(agent.ctx);
346
+ };
347
+ ctx.on("agent/created", ({ agent }) => {
348
+ install(agent);
349
+ });
350
+ for (const agent of ctx.get("agents")?.list() ?? []) install(agent);
351
+ }
371
352
  //#endregion
372
353
  //#region src/dialects.ts
373
354
  const SEPARATOR = "--";
@@ -448,7 +429,6 @@ var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
448
429
  super(ctx, upstreamConfigOf(config));
449
430
  this.access = config.access;
450
431
  }
451
- /** 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。 */
452
432
  get source() {
453
433
  return ruleSourceOf(this.access.get(), process.env);
454
434
  }
@@ -462,7 +442,6 @@ var ConfigurableSandboxProvider = class extends LocalSandboxProvider {
462
442
  argv: extendConfinedArgv(confined.argv, effective)
463
443
  };
464
444
  }
465
- /** 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。 */
466
445
  rulesFor(workspaceRoot) {
467
446
  const access = this.access.get();
468
447
  const key = `${workspaceRoot}\u0000${JSON.stringify(access ?? null)}`;
@@ -491,7 +470,7 @@ function apply(ctx, config) {
491
470
  warnAboutDegradedRules(ctx, rules);
492
471
  new ConfigurableSandboxProvider(ctx, config);
493
472
  new ConfigurableFileSystem(ctx, config);
494
- installPolicyContext(ctx, rules, (session) => ctx.sandboxPolicy.resolve({ session }));
473
+ installRuntimeContexts(ctx, rules, (session) => ctx.sandboxPolicy.resolve({ session }));
495
474
  }
496
475
  //#endregion
497
476
  export { Config, apply, inject, name };
package/dist/rows.d.cts CHANGED
@@ -1,23 +1,14 @@
1
1
  //#region src/rows.d.ts
2
- /**
3
- * 本包作为能力包发布的**装配数据**:行本身在部署那一层装(`packages/bundles/sandbox-profile` 引用它渲染 patch)。
4
- *
5
- * 官方 `sandbox`(进程沙箱)与 `fs-sandbox`(文件围栏)两个服务只能有一份实现,所以那两行禁掉、换成
6
- * 本包那一行;`config.access` 的值由部署那层给(schema 默认是空规则)。
7
- */
8
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
9
2
  interface PatchRow {
10
3
  readonly id?: string;
11
4
  readonly disabled?: boolean;
12
5
  readonly insert?: readonly RowEntry[];
13
6
  }
14
- /** `insert` 里的一个条目。 */
15
7
  interface RowEntry {
16
8
  readonly id: string;
17
9
  readonly name: string;
18
10
  readonly config?: Readonly<Record<string, unknown>>;
19
11
  }
20
- /** 沙箱替换的装配行:禁官方两行 + 插本包一行(值由部署那层按 id 覆盖)。 */
21
12
  declare function sandboxRows(): readonly PatchRow[];
22
13
  //#endregion
23
14
  export { PatchRow, RowEntry, sandboxRows };
package/dist/rows.d.mts CHANGED
@@ -1,23 +1,14 @@
1
1
  //#region src/rows.d.ts
2
- /**
3
- * 本包作为能力包发布的**装配数据**:行本身在部署那一层装(`packages/bundles/sandbox-profile` 引用它渲染 patch)。
4
- *
5
- * 官方 `sandbox`(进程沙箱)与 `fs-sandbox`(文件围栏)两个服务只能有一份实现,所以那两行禁掉、换成
6
- * 本包那一行;`config.access` 的值由部署那层给(schema 默认是空规则)。
7
- */
8
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
9
2
  interface PatchRow {
10
3
  readonly id?: string;
11
4
  readonly disabled?: boolean;
12
5
  readonly insert?: readonly RowEntry[];
13
6
  }
14
- /** `insert` 里的一个条目。 */
15
7
  interface RowEntry {
16
8
  readonly id: string;
17
9
  readonly name: string;
18
10
  readonly config?: Readonly<Record<string, unknown>>;
19
11
  }
20
- /** 沙箱替换的装配行:禁官方两行 + 插本包一行(值由部署那层按 id 覆盖)。 */
21
12
  declare function sandboxRows(): readonly PatchRow[];
22
13
  //#endregion
23
14
  export { PatchRow, RowEntry, sandboxRows };
package/dist/rows.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  //#region src/rows.ts
2
- /** 沙箱替换的装配行:禁官方两行 + 插本包一行(值由部署那层按 id 覆盖)。 */
3
2
  function sandboxRows() {
4
3
  return [
5
4
  {
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Configurable Sandbox",
4
- "description": "Replaces the shipped process sandbox and filesystem fence with implementations that add extra writable roots, read-only roots and access denials."
3
+ "title": "Configurable Sandbox & Approval",
4
+ "description": "Replaces the shipped process sandbox and filesystem fence with implementations that add extra writable roots, read-only roots and access denials, and owns the runtime-context snapshot (sandbox:policy / approval:policy) wording."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "可配置沙箱",
4
- "description": "替换官方的进程沙箱与文件系统围栏:在官方语义之上追加额外可写根、只读根与拒绝项。"
3
+ "title": "可配置沙箱与审批",
4
+ "description": "替换官方的进程沙箱与文件系统围栏:在官方语义之上追加额外可写根、只读根与拒绝项,并接管运行时快照(sandbox:policy / approval:policy)的措辞。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@morlay/dsh-sandbox-local",
3
- "version": "0.0.3",
4
- "description": "Configurable sandbox bundle: replaces the shipped process-sandbox provider and filesystem fence with implementations that add extra writable roots, read-only roots and access denials on top of the upstream semantics.",
3
+ "version": "0.0.5",
4
+ "description": "Configurable sandbox bundle: replaces the shipped process-sandbox provider and filesystem fence with implementations that add extra writable roots, read-only roots and access denials on top of the upstream semantics, and owns the runtime-context snapshot (`sandbox:policy` / `approval:policy`) with the deployment's own wording.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-bundle",
@@ -34,36 +34,38 @@
34
34
  "devDependencies": {
35
35
  "@deepseek-ai/cordis": "4.0.4",
36
36
  "@deepseek-ai/cosmokit": "1.8.5",
37
- "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
38
- "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
39
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
40
- "@deepseek-ai/dsh-fs": "0.1.7-rc.2",
41
- "@deepseek-ai/dsh-fs-local": "0.1.7-rc.2",
42
- "@deepseek-ai/dsh-fs-sandbox": "0.1.7-rc.2",
43
- "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
44
- "@deepseek-ai/dsh-sandbox-local": "0.1.7-rc.2",
45
- "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
46
- "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
47
- "@deepseek-ai/dsh-session": "0.1.7-rc.2",
48
- "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
49
- "@morlay/dsh-client-ui-primitives": "0.0.2"
37
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
38
+ "@deepseek-ai/dsh-agent-loop-testkit": "0.2.0-rc.1",
39
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
40
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
41
+ "@deepseek-ai/dsh-fs": "0.2.0-rc.1",
42
+ "@deepseek-ai/dsh-fs-local": "0.2.0-rc.1",
43
+ "@deepseek-ai/dsh-fs-sandbox": "0.2.0-rc.1",
44
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.1",
45
+ "@deepseek-ai/dsh-sandbox-local": "0.2.0-rc.1",
46
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.1",
47
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.1",
48
+ "@deepseek-ai/dsh-session": "0.2.0-rc.1",
49
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
50
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.1",
51
+ "@morlay/dsh-client-ui-primitives": "0.0.5"
50
52
  },
51
53
  "peerDependencies": {
52
54
  "@deepseek-ai/cordis": "^4.0.4",
53
55
  "@deepseek-ai/cosmokit": "^1.8.5",
54
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
55
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
56
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
57
- "@deepseek-ai/dsh-fs": "^0.1.7-rc.2",
58
- "@deepseek-ai/dsh-fs-local": "^0.1.7-rc.2",
59
- "@deepseek-ai/dsh-fs-sandbox": "^0.1.7-rc.2",
60
- "@deepseek-ai/dsh-sandbox": "^0.1.7-rc.2",
61
- "@deepseek-ai/dsh-sandbox-local": "^0.1.7-rc.2",
62
- "@deepseek-ai/dsh-sandbox-policy": "^0.1.7-rc.2",
63
- "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
64
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-fs": "^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-fs-local": "^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-fs-sandbox": "^0.2.0-rc.1",
62
+ "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.1",
63
+ "@deepseek-ai/dsh-sandbox-local": "^0.2.0-rc.1",
64
+ "@deepseek-ai/dsh-sandbox-policy": "^0.2.0-rc.1",
65
+ "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
65
67
  "@deepseek-ai/schemastery": "^3.18.4",
66
- "@morlay/dsh-client-ui-primitives": "0.0.2"
68
+ "@morlay/dsh-client-ui-primitives": "0.0.5"
67
69
  },
68
70
  "dsh": {
69
71
  "client": {
@@ -1,36 +1,31 @@
1
- /**
2
- * client 半:这一行的配置页由通用 schema 表单按 volatile 字段生成(`access` 是页面可改的那一项,改完规则
3
- * 当场重算),本包只把键名换成中文标签与说明。
4
- */
1
+ // client 半:这一行的配置页由通用 schema 表单按 volatile 字段生成(`access` 是页面可改的那一项,改完规则
2
+ // 当场重算),本包只把键名换成中文标签与说明。
5
3
 
6
4
  import type { Context } from "@deepseek-ai/cordis";
7
5
  import type {} from "@deepseek-ai/dsh-client-locale/client";
8
6
  import type {} from "@deepseek-ai/dsh-client-ui-slots";
9
7
  import { en, zh, type SandboxFieldLocaleKey } from "./locales.ts";
10
8
 
11
- /** 本包 host 行 id:行配置页读的就是这个命名空间。 */
9
+ // 本包 host 行 id:行配置页读的就是这个命名空间。
12
10
  export const SANDBOX_NS = "sandbox-local";
13
11
 
14
- /** 这一行里需要中文文案的字段。 */
12
+ // 这一行里需要中文文案的字段。
15
13
  const FIELDS = ["access"] as const;
16
14
 
17
- /** 本页字典的命名空间。 */
15
+ // 本页字典的命名空间。
18
16
  export const NS = "settings.sandbox-local";
19
17
 
20
18
  declare module "@deepseek-ai/dsh-client-ui-slots" {
21
19
  interface LocaleNamespaceMap {
22
- /** 设置页里这一行的字段文案。 */
20
+ // 设置页里这一行的字段文案。
23
21
  "settings.sandbox-local": SandboxFieldLocaleKey;
24
22
  }
25
23
  }
26
24
 
27
- /** 需要的服务:槽位(字段槽)与字典。 */
25
+ // 需要的服务:槽位(字段槽)与字典。
28
26
  export const inject = ["locale"];
29
27
 
30
- /**
31
- * 给 `access` 补文案。
32
- * @param ctx - 浏览器插件上下文。
33
- */
28
+ // 给 `access` 补文案。
34
29
  export function apply(ctx: Context): void {
35
30
  const t = ctx.locale.bind(NS);
36
31
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "sandbox-local: field locale");
@@ -1,15 +1,15 @@
1
- /** 本包在配置页上的字段文案(命名空间 `settings.sandbox-local`)。 */
1
+ // 本包在配置页上的字段文案(命名空间 `settings.sandbox-local`)。
2
2
 
3
3
  export const zh = {
4
4
  access: "额外可写根与拒绝项",
5
5
  accessHint:
6
- "一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;支持 `~`、环境变量与 glob。改完当场生效。",
6
+ "一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;支持环境变量模板与 glob(`rw` 项必须是具体路径)。改完当场生效。",
7
7
  } as const;
8
8
 
9
9
  export const en: Record<keyof typeof zh, string> = {
10
10
  access: "Extra writable roots and refusals",
11
11
  accessHint:
12
- "One entry per line: `rw:<path>` grants a writable root, `r-:<path>` makes it read-only, `--:<path>` denies it; `~`, env templates, and globs work. Edits apply immediately.",
12
+ "One entry per line: `rw <path>` grants a writable root, `r- <path>` makes it read-only, `-- <path>` denies it; env templates and globs work (`rw` entries must name a concrete path). Edits apply immediately.",
13
13
  };
14
14
 
15
15
  export type SandboxFieldLocaleKey = keyof typeof zh;
package/src/config.ts CHANGED
@@ -7,13 +7,9 @@ export interface Config extends UpstreamSandboxConfig, UpstreamFsConfig {
7
7
  access?: string | string[];
8
8
  }
9
9
 
10
- /**
11
- * schema 解析之后的形状:`access` 是 **volatile 稳定引用**(页面可改的那一项),读它要过 `.get()`。
12
- *
13
- * 其余字段也是 volatile,但页面把它们画成**只读**(schema 上的 `disabled()`):它们是装配事实——runner 命令、
14
- * 进程 cwd、上游基类读一次的超时与差额上限——放在页面上是为了看清「这一行现在装配成什么样」,改它们要重挂
15
- * 这一行。所以给上游基类的那份配置是**解包后的值**({@link upstreamConfigOf}):基类读的是值,不是引用。
16
- */
10
+ // schema 解析之后的形状:`access` 是 volatile 稳定引用(读它要过 `.get()`);其余字段也是 volatile,
11
+ // 但 schema 上标了 `disabled()`,页面把它们画成**只读**的装配事实。所以给上游基类的那份配置是
12
+ // **解包后的值**(`upstreamConfigOf`)——基类读的是值,不是引用。
17
13
  export interface ResolvedConfig extends Omit<
18
14
  Config,
19
15
  | "access"
@@ -31,10 +27,8 @@ export interface ResolvedConfig extends Omit<
31
27
  readonly diffBasisMaxBytes: Volatile<number>;
32
28
  }
33
29
 
34
- /**
35
- * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
36
- * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
37
- */
30
+ // 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
31
+ // (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
38
32
  const localized = (text: { zh: string; en: string }): string => text as unknown as string;
39
33
 
40
34
  export const Config: z<Config, ResolvedConfig> = z.object({
@@ -44,11 +38,12 @@ export const Config: z<Config, ResolvedConfig> = z.object({
44
38
  .description(
45
39
  localized({
46
40
  zh:
47
- "额外可写根与拒绝项,一行一条:`rw:<路径>` 追加可写根,`r-:<路径>` 只读,`--:<路径>` 拒绝;" +
48
- "支持 `~`、环境变量与 glob。改它当场生效(沙箱规则每次按当前值重算),不用重挂这一行。",
41
+ "额外可写根与拒绝项,一行一条:`rw <路径>` 追加可写根,`r- <路径>` 只读,`-- <路径>` 拒绝;" +
42
+ "路径可用环境变量模板与 glob(`rw` 项必须是具体路径)。改它当场生效(沙箱规则每次按当前值重算),不用重挂这一行。",
49
43
  en:
50
- "Extra writable roots and refusals, one entry per line: `rw:<path>` grants a writable root, " +
51
- "`r-:<path>` makes it read-only, `--:<path>` denies it; `~`, env templates, and globs are supported. " +
44
+ "Extra writable roots and refusals, one entry per line: `rw <path>` grants a writable root, " +
45
+ "`r- <path>` makes it read-only, `-- <path>` denies it; env templates and globs work " +
46
+ "(`rw` entries must name a concrete path). " +
52
47
  "Edits take effect on the next rule compile without remounting the row.",
53
48
  }),
54
49
  )
@@ -110,14 +105,8 @@ export const Config: z<Config, ResolvedConfig> = z.object({
110
105
  .volatile(),
111
106
  });
112
107
 
113
- /**
114
- * 交给上游基类的那份配置:装配事实取当前值。
115
- *
116
- * 上游 `LocalSandboxProvider` / `LocalFileSystem` 在构造时读一次这些字段并做装配期校验,所以它们要的是值;
117
- * 设置页改它们走的是「写盘 → Loader 重挂这一行 → 重新解析」这条路。
118
- * @param config - 本行解析后的配置。
119
- * @returns 上游两个基类认的普通配置对象。
120
- */
108
+ // 交给上游基类的配置:装配事实取当前值——基类在构造时读一次这些字段并做装配期校验,所以它们要的是值;
109
+ // 设置页改它们走「写盘 → Loader 重挂这一行 → 重新解析」。返回上游两个基类认的普通配置对象。
121
110
  export function upstreamConfigOf(config: ResolvedConfig): UpstreamSandboxConfig & UpstreamFsConfig {
122
111
  return {
123
112
  runnerCommand: [...config.runnerCommand.get()],
package/src/fs.ts CHANGED
@@ -37,7 +37,7 @@ export class ConfigurableFileSystem extends LocalFileSystem {
37
37
  this.access = config.access;
38
38
  }
39
39
 
40
- /** 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。 */
40
+ // 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。
41
41
  private get source(): RuleSource {
42
42
  return ruleSourceOf(this.access.get(), process.env);
43
43
  }
@@ -134,7 +134,7 @@ export class ConfigurableFileSystem extends LocalFileSystem {
134
134
  );
135
135
  }
136
136
 
137
- /** 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。 */
137
+ // 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。
138
138
  private rulesFor(workspaceRoot: string): CompiledRules {
139
139
  const access = this.access.get();
140
140
  const key = `${workspaceRoot}\u0000${JSON.stringify(access ?? null)}`;
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import type { ResolvedConfig } from "./config.ts";
3
3
  import { ConfigurableFileSystem } from "./fs.ts";
4
- import { installPolicyContext } from "./policy.ts";
4
+ import { installRuntimeContexts } from "./policy.ts";
5
5
  import { ruleSourceOf, type RuleSource } from "./rules.ts";
6
6
  import { ConfigurableSandboxProvider } from "./sandbox.ts";
7
7
 
@@ -39,6 +39,7 @@ export function apply(ctx: Context, config: ResolvedConfig): void {
39
39
  warnAboutDegradedRules(ctx, rules);
40
40
  new ConfigurableSandboxProvider(ctx, config);
41
41
  new ConfigurableFileSystem(ctx, config);
42
- // 替换了 ctx.sandbox / ctx.fs,`sandbox:policy` 那段运行时文本也要跟着换(见 policy.ts)。
43
- installPolicyContext(ctx, rules, (session) => ctx.sandboxPolicy.resolve({ session }));
42
+ // 替换了 ctx.sandbox / ctx.fs,`sandbox:policy` 那段运行时文本也要跟着换,`approval:policy` 换成中文
43
+ // (见 policy.ts)。
44
+ installRuntimeContexts(ctx, rules, (session) => ctx.sandboxPolicy.resolve({ session }));
44
45
  }
package/src/policy.ts CHANGED
@@ -3,96 +3,126 @@ import type { Context } from "@deepseek-ai/cordis";
3
3
  import type { SandboxExecutionPolicy } from "@deepseek-ai/dsh-sandbox";
4
4
  import type { Session } from "@deepseek-ai/dsh-session";
5
5
  import type {} from "@deepseek-ai/dsh-system-prompt";
6
+ // Type-only:`approval:policy` 的取值面(`ApprovalPolicy` 与 `ApprovalService.overrideOf`)。
7
+ import type { ApprovalPolicy } from "@deepseek-ai/dsh-user-approval";
6
8
  import type { RuleSource } from "./rules.ts";
7
9
 
8
- /**
9
- * 接管 `sandbox:policy` 那条运行时上下文。
10
- *
11
- * 上游 [`@deepseek-ai/dsh-sandbox-policy`] 在**全局层**注册它,文本只描述官方策略(只读 /
12
- * workspace-write / 全权 + workspace root)——它不知道本部署追加的 `rw` / `r-` / `--` 规则,
13
- * 模型因此拿不到"哪些额外路径可写、哪些被拒"。本包替换了 `ctx.sandbox` / `ctx.fs`,这条文本也得跟着换。
14
- *
15
- * 全局层同名注册会抛错(`NamedEntries.insert`),上游给的官方路径是**按 agent 作用域覆盖**:
16
- * `systemPrompt.context()` 在 scope 上注册同名项,装配时近的作用域遮蔽全局那条
17
- * (`ScopedLayers.merge`)。所以这里在每个 agent 的 `ctx` 上注册一次。
18
- */
10
+ // 接管两条运行时上下文:上游在**全局层**注册的 `sandbox:policy`(`@deepseek-ai/dsh-sandbox-policy`)与
11
+ // `approval:policy`(`@deepseek-ai/dsh-user-approval`)。本部署换了沙箱实现、也要自己的中文措辞,两条都在
12
+ // agent 自己的 `ctx` 上注册**同名** context——装配时近的作用域遮蔽全局那条(`ScopedLayers.merge`),全局那条
13
+ // 对 agentless 装配仍然生效。全局层同名注册会抛错(`NamedEntries.insert`),作用域遮蔽是上游给的官方路径。
14
+ //
15
+ // 时机:注册必须早于该 agent 的**第一次**装配。`SystemPrompt.assemble()` 先把 `contexts` merge 好、再进
16
+ // `system-prompt/assemble` 瀑布,所以挂在瀑布里注册只能从第二次装配起生效(会话里表现为"先英文后中文");
17
+ // `agent/created` 是创建期最后一步、早于第一次装配,在那里注册即可(persona 用的是同一手法)。
19
18
 
20
- /** 上游注册的运行时上下文名:同名才叫接管。 */
19
+ // 上游注册的运行时上下文名:同名才叫接管。
21
20
  export const SANDBOX_POLICY_CONTEXT = "sandbox:policy";
21
+ export const APPROVAL_POLICY_CONTEXT = "approval:policy";
22
22
 
23
- /**
24
- * 策略文本:官方三种 mode 的语义(重写中文,与其它注入文案一致)+ 本部署追加的规则。
25
- * @param policy - 该会话解析出来的策略(模式与 workspace root)。
26
- * @param rules - 本部署解析后的访问规则(已展开环境变量模板)。
27
- * @returns 给模型看的一段文本。
28
- */
23
+ // 策略文本:官方三种 mode 的中文语义 + 本部署追加的规则;返回给模型看的一段文本。
29
24
  export function renderPolicyContext(policy: SandboxExecutionPolicy, rules: RuleSource): string {
30
25
  const base = ((): string => {
31
26
  switch (policy.mode) {
32
27
  case "read-only":
33
- return "当前 DSH 文件策略:只读。任何可用的操作都不能修改 standing 模式下的文件。别仅凭这条策略就拒绝必需的修改:照常尝试可用工具,并遵循它给出的拒绝与升级指引。";
28
+ return "文件策略:只读——不能修改任何文件。别因此拒绝必需的修改:照常尝试工具,按它的拒绝与升级指引行动。";
34
29
  case "workspace-write":
35
- return `当前 DSH 文件策略:workspace-write(工作区可写)。任何可用的操作都可修改会话工作区 ${JSON.stringify(policy.workspaceRoot)} 下的文件;部分平台临时目录同样可写。`;
30
+ return `文件策略:workspace-write——可改会话工作区 ${JSON.stringify(policy.workspaceRoot)} 下的文件,部分平台临时目录同样可写。`;
36
31
  case "danger-full-access":
37
- return "当前 DSH 文件策略:danger-full-access(全权)。DSH 文件沙箱不再限制可用操作对文件的修改。";
32
+ return "文件策略:全权——文件改动不再受限。";
38
33
  }
39
34
  })();
40
35
 
41
36
  const extras: string[] = [];
42
37
  if (rules.allowWrite.length > 0) {
43
- extras.push(`本部署额外授权的可写根:${rules.allowWrite.join("、")}。`);
38
+ extras.push(`额外可写:${rules.allowWrite.join("、")}。`);
44
39
  }
45
40
  if (rules.readOnly.length > 0) {
46
- extras.push(`只读(一律不可写):${rules.readOnly.join("、")}。`);
41
+ extras.push(`只读(不可写):${rules.readOnly.join("、")}。`);
47
42
  }
48
43
  if (rules.deny.length > 0) {
49
- extras.push(`拒绝访问(读与写都拒):${rules.deny.join("、")}。`);
44
+ extras.push(`拒绝(读写都拒):${rules.deny.join("、")}。`);
50
45
  }
51
46
  return extras.length === 0 ? base : `${base} ${extras.join("")}`;
52
47
  }
53
48
 
54
- /**
55
- * 在给定作用域注册同名策略文本(agent 的 `ctx`,或 preset 子树)。
56
- * @param scope - 拥有这次注册的 ctx;它的 scope 因此遮蔽全局那条。
57
- * @param rules - 本部署的访问规则。
58
- * @param resolve - 按会话解析策略(生产传 `ctx.sandboxPolicy.resolve`)。
59
- */
60
- export function registerPolicyContext(
49
+ // `approval:policy` 的两段文本。`never` 是确定性拒绝,所以顺带告诉模型别去要沙箱升级。
50
+ const APPROVAL_ASK_TEXT = "审批策略:ask——需要审批的操作会询问用户;没有可用的应答者时直接失败。";
51
+ const APPROVAL_NEVER_TEXT =
52
+ "审批提示已禁用:需要审批的操作一律自动拒绝——不要请求沙箱升级(不要设 sandbox_permissions)。";
53
+
54
+ // 策略文本按会话的有效策略选段。
55
+ export function renderApprovalContext(policy: ApprovalPolicy): string {
56
+ return policy === "never" ? APPROVAL_NEVER_TEXT : APPROVAL_ASK_TEXT;
57
+ }
58
+
59
+ // 上游 `ApprovalService` 的公开面:`overrideOf`(会话日志里最后一条 `approval/policy`)与 `config.policy`
60
+ // (部署默认)。`effectivePolicy` 是私有的,所以在这里复刻它的同一算法(override 优先,否则 config 默认,
61
+ // 再退到 `ask`——schema 已经把缺省填成 `ask`,这一层只是兜住手写的装配 config)。
62
+ interface ApprovalSource {
63
+ overrideOf(session: Session): ApprovalPolicy | undefined;
64
+ readonly config: { readonly policy?: ApprovalPolicy };
65
+ }
66
+
67
+ // 某个会话此刻生效的审批策略。
68
+ function effectiveApprovalPolicy(approval: ApprovalSource, session: Session): ApprovalPolicy {
69
+ return approval.overrideOf(session) ?? approval.config.policy ?? "ask";
70
+ }
71
+
72
+ // 在给定作用域注册同名策略文本(agent 的 `ctx`)——该 scope 因此遮蔽全局那条。
73
+ // `ctx.get` 而不是 `ctx.systemPrompt`:本行没在 `inject` 里点名 systemPrompt,属性访问会被 cordis 拒绝。
74
+ function registerPolicyContext(
61
75
  scope: Context,
62
76
  rules: RuleSource,
63
77
  resolve: (session: Session) => SandboxExecutionPolicy,
64
78
  ): void {
65
- // 回调参数 ctx 才是解锁了 `systemPrompt` 的那个 ctx,注册也就落在它的 scope 上。
66
- scope.inject(["systemPrompt"], (scoped) => {
67
- scoped.systemPrompt.context({
68
- name: SANDBOX_POLICY_CONTEXT,
69
- order: scoped.systemPrompt.getContextOrder("SANDBOX_POLICY"),
70
- text: (context) => {
71
- const session = context.agent?.session;
72
- return session === undefined ? "" : renderPolicyContext(resolve(session), rules);
73
- },
74
- });
79
+ const prompt = scope.get("systemPrompt");
80
+ if (prompt === undefined) return;
81
+ prompt.context({
82
+ name: SANDBOX_POLICY_CONTEXT,
83
+ order: prompt.getContextOrder("SANDBOX_POLICY"),
84
+ text: (context) => {
85
+ const session = context.agent?.session;
86
+ return session === undefined ? "" : renderPolicyContext(resolve(session), rules);
87
+ },
75
88
  });
76
89
  }
77
90
 
78
- /**
79
- * 每个 agent 注册一次(装配期做,与 scope 出口同一时机与理由:创建期服务可用性还在变)。
80
- * @param ctx - 本插件的 ctx(host 平面)。
81
- * @param rules - 本部署的访问规则。
82
- * @param resolve - 按会话解析策略。
83
- */
84
- export function installPolicyContext(
91
+ // 同上,接管 `approval:policy`:读的是上游服务公开面里的有效策略,文本按它选段。
92
+ function registerApprovalContext(scope: Context): void {
93
+ const prompt = scope.get("systemPrompt");
94
+ if (prompt === undefined) return;
95
+ prompt.context({
96
+ name: APPROVAL_POLICY_CONTEXT,
97
+ order: prompt.getContextOrder("APPROVAL_POLICY"),
98
+ text: (context) => {
99
+ const agent = context.agent;
100
+ if (agent === undefined) return "";
101
+ // 审批能力是可选的(headless 部署可能没装):没装就按"没有这条"读,空串遮蔽不了任何东西。
102
+ const approval = agent.ctx.get("approval") as ApprovalSource | undefined;
103
+ return approval === undefined
104
+ ? ""
105
+ : renderApprovalContext(effectiveApprovalPolicy(approval, agent.session));
106
+ },
107
+ });
108
+ }
109
+
110
+ // 每个 agent 注册一次,时机是它创建时——必须早于第一次装配(见文件头)。
111
+ export function installRuntimeContexts(
85
112
  ctx: Context,
86
113
  rules: RuleSource,
87
114
  resolve: (session: Session) => SandboxExecutionPolicy,
88
115
  ): void {
89
- const registered = new WeakSet<Agent>();
90
- ctx.on("system-prompt/assemble", (_assembly, context, next) => {
91
- const agent = context.agent as Agent | undefined;
92
- if (agent !== undefined && !registered.has(agent)) {
93
- registered.add(agent);
94
- registerPolicyContext(agent.ctx, rules, resolve);
95
- }
96
- return next();
116
+ const installed = new WeakSet<Agent>();
117
+ const install = (agent: Agent): void => {
118
+ if (installed.has(agent)) return;
119
+ installed.add(agent);
120
+ registerPolicyContext(agent.ctx, rules, resolve);
121
+ registerApprovalContext(agent.ctx);
122
+ };
123
+ ctx.on("agent/created", ({ agent }) => {
124
+ install(agent);
97
125
  });
126
+ // 本行晚于已有 agent 挂载时(行重挂 / HMR)也要覆盖它们:`agent/created` 不会再为它们发一次。
127
+ for (const agent of ctx.get("agents")?.list() ?? []) install(agent);
98
128
  }
package/src/rows.ts CHANGED
@@ -1,25 +1,22 @@
1
- /**
2
- * 本包作为能力包发布的**装配数据**:行本身在部署那一层装(`packages/bundles/sandbox-profile` 引用它渲染 patch)。
3
- *
4
- * 官方 `sandbox`(进程沙箱)与 `fs-sandbox`(文件围栏)两个服务只能有一份实现,所以那两行禁掉、换成
5
- * 本包那一行;`config.access` 的值由部署那层给(schema 默认是空规则)。
6
- */
1
+ // 本包作为能力包发布的**装配数据**:行本身在部署那一层装(`packages/bundles/sandbox-profile` 引用它
2
+ // 渲染 patch)。官方 `sandbox` 与 `fs-sandbox` 两个服务只能有一份实现,所以那两行禁掉、换成本包那一行;
3
+ // `config.access` 的值由部署那层给(schema 默认是空规则)。
7
4
 
8
- /** 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。 */
5
+ // 一行装配条目:与 `cordis.patch.yml` 的顶层结构同形。
9
6
  export interface PatchRow {
10
7
  readonly id?: string;
11
8
  readonly disabled?: boolean;
12
9
  readonly insert?: readonly RowEntry[];
13
10
  }
14
11
 
15
- /** `insert` 里的一个条目。 */
12
+ // `insert` 里的一个条目。
16
13
  export interface RowEntry {
17
14
  readonly id: string;
18
15
  readonly name: string;
19
16
  readonly config?: Readonly<Record<string, unknown>>;
20
17
  }
21
18
 
22
- /** 沙箱替换的装配行:禁官方两行 + 插本包一行(值由部署那层按 id 覆盖)。 */
19
+ // 沙箱替换的装配行:禁官方两行 + 插本包一行(值由部署那层按 id 覆盖)。
23
20
  export function sandboxRows(): readonly PatchRow[] {
24
21
  return [
25
22
  { id: "sandbox", disabled: true },
package/src/sandbox.ts CHANGED
@@ -23,7 +23,7 @@ export class ConfigurableSandboxProvider extends LocalSandboxProvider {
23
23
  this.access = config.access;
24
24
  }
25
25
 
26
- /** 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。 */
26
+ // 规则源现场重算:`access` 是 volatile 引用,页面改完下一次编译就是新规则。
27
27
  private get source(): RuleSource {
28
28
  return ruleSourceOf(this.access.get(), process.env);
29
29
  }
@@ -40,7 +40,7 @@ export class ConfigurableSandboxProvider extends LocalSandboxProvider {
40
40
  return { ...confined, argv: extendConfinedArgv(confined.argv, effective) };
41
41
  }
42
42
 
43
- /** 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。 */
43
+ // 编译结果按「工作区 + 当前 access」缓存:access 是页面可改的引用,改了就是另一份规则。
44
44
  private rulesFor(workspaceRoot: string): CompiledRules {
45
45
  const access = this.access.get();
46
46
  const key = `${workspaceRoot}\u0000${JSON.stringify(access ?? null)}`;