@gavinc-cn/dsh-path-rules 0.1.0 → 0.1.2

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
@@ -10,8 +10,12 @@ DSH 第五权限模式「路径规则」的逐调用闸门:挂在 `tools/pre-e
10
10
  - 项目外目录**只读**,写入需要用户审批;
11
11
  - **白名单**内的目录可读可写(直通,不弹框);
12
12
  - **黑名单**内的目录读写**都要审批**(优先级最高,压过白名单与项目内);
13
- - `bash` / `pwsh`:只读命令直接放行;写入目标可静态解析时按上述规则判定;
14
- 解析不出的命令默认拒绝并提示模型改写成可判定形式。
13
+ - `bash` / `pwsh`:只读命令直接放行;写入目标可静态解析时按上述规则判定;**解析不出的命令默认交给沙箱**
14
+ (workspace-write 的运行时写拦截,与「工作区内修改」档一致),可用 `unanalyzable: deny|ask` 改回严格;
15
+ - **白名单写入预置升权**(`preemptiveWhitelistEscalation`,默认开):静态可判定的白名单写一次成功、不弹框
16
+ (预置 danger-full-access + 插件自动 `allowed-once`,见下文);
17
+ - **自动白名单**(可选,`autoWhitelist: true`):用户在审批框里批准的沙箱升权写入目标,
18
+ 其目录会自动进入白名单(持久化),下次同目录的写入不再弹框。见下文。
15
19
 
16
20
  ## 激活条件
17
21
 
@@ -28,8 +32,8 @@ DSH 第五权限模式「路径规则」的逐调用闸门:挂在 `tools/pre-e
28
32
  ```
29
33
  黑名单命中 → ask 白名单命中 → allow 项目内 → allow
30
34
  临时区(/tmp、os.tmpdir())→ allow 项目外读 → allow
31
- 项目外写 → ask
32
- shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
35
+ 项目外写 → ask(工作区外写交给沙箱升权)
36
+ shell 判不出 → 黑名单文本粗检(命中 ⇒ ask)→ 否则按 `unanalyzable` 策略(默认 pass,交给沙箱)
33
37
  ```
34
38
 
35
39
  `ask` 走框架标准审批流程(`ctx.approval.request`);会话审批通道为 `never`(子代理被框架钉成
@@ -47,14 +51,99 @@ shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
47
51
  whitelist: [] # 绝对路径;可读可写,压过"项目外",但被黑名单压过
48
52
  blacklist: [] # 绝对路径;读写都要审批,压过一切
49
53
  allowTempRoots: true # /tmp + os.tmpdir() 视为可写(与沙箱口径一致)
50
- unanalyzable: deny # deny | ask:shell 判不出时的兜底
54
+ unanalyzable: pass # deny | ask | pass:shell 判不出时的兜底(pass=交给沙箱,v2 默认)
51
55
  failMode: closed # closed | open:插件内部异常时的兜底
52
56
  dryRun: false # true = 只记日志不拦(灰度用)
53
57
  modelFallback: false # 预留字段:置 true 仅记一条 warn 日志,判定行为不变
58
+ autoWhitelist: false # true = 用户批准的沙箱升权写入目标自动进白名单(默认关闭)
59
+ preemptiveWhitelistEscalation: true # 白名单写预置升权:一次成功、不弹框(v2 默认开)
60
+ opaqueBlacklistScan: true # 判不出命令的黑名单文本粗检(黑名单读的唯一兜底,v2 默认开)
61
+ autoWhitelistFile: <dsh home>/path-rules/auto-whitelist.json # 自动白名单状态文件(可选)
54
62
  logPath: ~/.kimi-code/hooks/logs/path_rules.dsh.log
55
63
  ```
56
64
 
57
- 日志事件:`plugin-loaded / allow / ask / deny / pass / dry-run / error`。
65
+ 日志事件:`plugin-loaded / allow / ask / deny / pass / dry-run / error`,
66
+ 以及自动白名单的 `auto-whitelist / auto-whitelist-skip / auto-whitelist-load /
67
+ auto-whitelist-save / auto-whitelist-error`。
68
+
69
+ ## 自动白名单(`autoWhitelist`,2026-10-18 新增)
70
+
71
+ **用途**:路径规则档下,项目外写入的审批通路是"沙箱拒绝 → 模型带 `sandbox_permissions` 重试 →
72
+ 框架弹审批框"。同一个目录反复写就会反复弹框。打开 `autoWhitelist` 后,用户**批准过一次**的写入目标,
73
+ 其目录会被自动记进白名单;之后同一目录的升权请求由本插件直接答 `allowed-once`(不弹框)。
74
+
75
+ **触发条件**(缺一不可):
76
+
77
+ 1. `autoWhitelist: true` 且 `enabled: true`、`dryRun: false`;
78
+ 2. 会话当前处于「路径规则」档(`activationPreset` 命中,子代理沿父链上溯;其它四档不学习);
79
+ 3. 审批类型是**沙箱升权**(`reason` 以 `escalate sandbox to ` 开头);
80
+ 4. 用户在审批框点了同意(`approval/request` 瀑布下游返回 `allowed-once`;拒绝/取消/不可用都不学)。
81
+
82
+ **学什么**:从该次调用的参数静态解析写入目标 —— `write`/`edit` 的 `file_path`、`bash`/`pwsh` 的
83
+ 写目标(复用 `analyze.mjs`);目标是已存在的目录就取它自己,否则取父目录。**解析不出就什么都不学**
84
+ (不猜):MCP/未知工具、判不出的 shell 命令、参数没被记住的调用都只记一条 skip 日志。
85
+
86
+ **生效方式**:条目并入内存白名单(下一条消息起系统提示里可见),此后:
87
+
88
+ - 该目录的写入由本插件直接 `allow`(但**沙箱仍会先拒绝首次写入** —— 跨层限制见下);
89
+ - 模型按提示带 `sandbox_permissions` 重试时,`approval/request` 命中白名单 ⇒ 插件答 `allowed-once`,
90
+ 用户**不再看到弹框**。
91
+
92
+ 命令类(`bash`/`pwsh`)升权请求的命中判定 2026-10-18 起改为**按静态写目标**:全部写目标都落在
93
+ 白名单/项目内/临时区、且至少一个命中白名单才自动放行;任一目标命中黑名单或项目外就交给用户。
94
+ 只有命令判不出写目标时才退回旧的「命令文本包含白名单路径」粗粒度口径。
95
+ (旧口径会把 `cp /white/f.txt /elsewhere/f.txt` 这类命令静默放行;自动白名单让白名单动态变化后必须收紧。)
96
+
97
+ **持久化**:默认状态文件 `<dsh home>/path-rules/auto-whitelist.json`(可用 `autoWhitelistFile` 覆盖,
98
+ 支持 `~`),格式:
99
+
100
+ ```json
101
+ {
102
+ "version": 1,
103
+ "entries": [
104
+ { "path": "/srv/data", "platform": "linux", "addedAt": "2026-10-18T06:00:00.000Z", "tool": "write", "callId": "call_..." }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ - 按 `process.platform` **分区存放**:容器(linux)与宿主(win32)共享同一份 `~/.dsh` 时互不串味,
110
+ 写回时保留另一平台的条目;跨平台写法的路径在加载时直接忽略。
111
+ - 原子写(临时文件 + rename)、坏文件容错(只记 error 日志,不影响插件加载)、上限 200 条
112
+ (超出淘汰最旧)。
113
+ - 用户可直接编辑/删除该文件;**生效时机 = 插件重新加载**(改本行 config 是 patch 层热加载约 2 秒;
114
+ 改代码需重启 dsh)。`loader` 日志里 `auto-whitelist loaded=N` 可确认读到了多少条。
115
+
116
+ **安全护栏**(自动加入前逐条过滤,丢弃原因写进日志):
117
+
118
+ | 跳过原因 | 含义 |
119
+ |---|---|
120
+ | `blacklist` | 目标/目录命中黑名单 ⇒ 不学(黑名单优先级最高,加了也无效) |
121
+ | `no-need` | 目标在项目内或临时区,本来就直通,无需白名单 |
122
+ | `already-whitelisted` | 目录已被现有白名单覆盖 |
123
+ | `protected` | 与插件状态/日志文件或 DSH home 相关(防"自动白名单自己改写自己") |
124
+ | `sensitive` | 目录是系统根(`/`、`/etc`、`/usr`、`/var`、`/opt` …)或用户 home **自身或其祖先** |
125
+
126
+ 即:批准一个文件**不会**被放大成整个 home/系统目录的长期信任;`/root/data` 这类更深层目录可以自动加入,
127
+ 但 `/root` 本身不行(要用就手工写进 config 的 `whitelist`)。
128
+
129
+ **跨层限制(重要)**:自动白名单**不突破沙箱**。工作区外第一次写入仍会被 `workspace-write` 拒绝一次,
130
+ 模型需按错误提示带 `sandbox_permissions` + `justification` 重试;区别只在于重试的审批请求
131
+ 由插件自动放行、不再弹框。详见下文「已知盲区 8」。
132
+
133
+ ## 白名单写入的预置升权(`preemptiveWhitelistEscalation`,2026-10-18 v2)
134
+
135
+ **用途**:白名单目录在沙箱可写根(会话工作区 + 临时区)之外,静态判定放行后沙箱仍会先拒绝一次,
136
+ 模型需带 `sandbox_permissions` 重试、插件再自动放行。预置升权把这一步提前,让白名单写**一次成功**。
137
+
138
+ **条件(全部满足)**:开启且非 `dryRun`;工具是 `bash`/`pwsh`;静态分析判得出写目标且非空;每个写目标
139
+ ∈ 白名单/项目内/临时区;至少一个命中白名单;至少一个在沙箱可写根之外;调用参数里没有
140
+ `sandbox_permissions`/`justification`(尊重模型自己的升权请求)。
141
+
142
+ **机制**:pre-execute 里给该次调用预置 `sandbox_permissions: danger-full-access` + justification;
143
+ bash/pwsh 工具随后发起升权审批,插件自己的 `approval/request` 监听器按白名单自动答 `allowed-once`
144
+ (不弹框)。执行策略与"模型重试后自动放行"完全相同,不新增放权面;注入失败(参数不可写等)只记
145
+ `preempt-escalation-error` 日志并退回原链路。方案 C(自研 sandbox provider 让白名单成为运行时 RW 根)
146
+ 落地后应关闭本项(`preemptiveWhitelistEscalation: false`),否则会绕过 C 的运行时拦截。
58
147
 
59
148
  ## 已知盲区(未覆盖,别当安全)
60
149
 
@@ -71,6 +160,19 @@ shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
71
160
  项目外/白名单(工作区外)的写会被沙箱拦下,模型需带 `sandbox_permissions` + `justification`
72
161
  重试同一调用以触发框架审批;白名单目标的升权请求由本插件自动答 `allowed-once`(无弹框)。
73
162
  详见 spec §4.4。
163
+ 9. **自动白名单只学"能静态解析写目标"的调用**:MCP/未知工具、`unanalyzable` 的 shell、参数未被记忆的
164
+ 调用都不学习(只记 skip 日志);`bash` 的间接产物(脚本内部、后台进程)同既有盲区。
165
+ 10. **自动白名单是父目录粒度**:批准 `/a/b/f.txt` 会把整个 `/a/b` 加入白名单(用户语义如此);
166
+ 敏感根保护只挡系统根与 home 自身/祖先,不挡更深的目录。
167
+ 11. **命令判不出写目标时升权放行退回子串口径**:`bash`/`pwsh` 命令若静态分析判不出(`unanalyzable`、
168
+ 参数未记忆),仍按"命令文本包含白名单路径"粗粒度放行 —— 这是旧插件沿用口径,无法在不分析的前提下收紧。
169
+ 12. **判不出命令的黑名单「读」**:沙箱只拦写、不拦读,本插件对判不出的命令只剩"命令文本粗检"
170
+ (`opaqueBlacklistScan`);脚本内部/间接读取(如脚本文件里读黑名单路径)不可见。
171
+ 13. **判不出命令的白名单「写」**:无法预判写目标 ⇒ 仍走"沙箱首拒 → 升权重试";且升权审批因解析不出目标
172
+ 会弹用户框、`autoWhitelist` 也学不到(`writableDirsOf` 对 unanalyzable 返回空)。
173
+ 14. **预置升权(`preemptiveWhitelistEscalation`)使该次调用失去沙箱写兜底**:与既有"模型重试后插件自动
174
+ 放行"属同一信任级别(最终都是 danger-full-access),但仍依赖静态分析正确;可用
175
+ `preemptiveWhitelistEscalation: false` 关闭,退回"首拒 + 重试"链路。
74
176
 
75
177
  ## 测试
76
178
 
@@ -78,10 +180,11 @@ shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
78
180
  cd packages/runtime/dsh-path-rules
79
181
  node --test test/rules.test.mjs test/classify.test.mjs test/analyze-lex.test.mjs \
80
182
  test/analyze-bash.test.mjs test/analyze-pwsh.test.mjs test/decide.test.mjs \
81
- test/plugin.test.mjs test/analyze-security.test.mjs
183
+ test/plugin.test.mjs test/analyze-security.test.mjs test/auto-whitelist.test.mjs
82
184
  ```
83
185
 
84
- (Node v24 下 `node --test test/` 目录形式会失败,必须显式给出文件。)
186
+ (Node v24 下 `node --test test/` 目录形式会失败,必须显式给出文件;`npm test` 已包含全部 9 个文件。)
85
187
 
86
- 设计文档:`docs/superpowers/specs/2026-10-16-dsh-path-rules-permission-mode-design.md`;
188
+ v2 设计(路径规则 = workspace-write 超集,2026-10-18):`docs/superpowers/specs/2026-10-18-path-rules-v2-superset-design.md`;
189
+ 设计文档(v1):`docs/superpowers/specs/2026-10-16-dsh-path-rules-permission-mode-design.md`;
87
190
  实现计划:`docs/superpowers/plans/2026-10-17-dsh-path-rules-permission-mode.md`。
@@ -0,0 +1,260 @@
1
+ // dsh-path-rules 的自动白名单(2026-10-18 新增)。
2
+ //
3
+ // 目的:用户批准过一次「沙箱升权写入」后,把该次写入目标折叠成的**目录**记进白名单,
4
+ // 之后同一目录的升权请求由插件自动答 allowed-once、不再反复弹框。
5
+ //
6
+ // 三层职责(全部可独立测试,index.mjs 只做接线):
7
+ // ① writableDirsOf :从一次工具调用(工具名 + 参数)推导写入目标 → 父目录;
8
+ // ② filterAutoWhitelistDirs:安全护栏(黑名单/项目内/临时区/已覆盖/保护路径/敏感根);
9
+ // ③ loadAutoWhitelist / saveAutoWhitelist:按平台分区的 JSON 状态文件读写(原子写)。
10
+ //
11
+ // 重要边界:本模块只负责「学什么、记到哪」,**不改变**判定链本身的优先级
12
+ // (黑名单仍压过白名单;自动条目只是追加进 config.whitelist)。
13
+
14
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, isAbsolute, join } from 'node:path'
17
+ import { analyzeCommand } from './analyze.mjs'
18
+ import { classifyTool } from './classify.mjs'
19
+ import { classifyPath, normalizePath, underRoot } from './rules.mjs'
20
+
21
+ /** 自动白名单条数上限(每个平台),超出淘汰最旧。 */
22
+ export const MAX_AUTO_ENTRIES = 200
23
+
24
+ /** 状态文件格式版本。 */
25
+ const STATE_VERSION = 1
26
+
27
+ /** POSIX 系统目录:既不允许自身也不允许它们的祖先目录被自动加入(更深目录可以)。 */
28
+ const POSIX_SENSITIVE_ROOTS = ['/', '/etc', '/usr', '/bin', '/sbin', '/boot', '/var', '/lib', '/lib64', '/opt', '/proc', '/sys', '/dev']
29
+
30
+ /** 展开 `~` / `~/` / `~\` 前缀;其它写法原样返回。 */
31
+ export function expandHome(path, home = homedir()) {
32
+ if (typeof path !== 'string') return path
33
+ if (path === '~') return home
34
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
35
+ return path
36
+ }
37
+
38
+ /**
39
+ * 目标路径 → 该自管目录:目标本身是已存在的目录就取它自己,否则取父目录。
40
+ * (批准后写入目标往往还没创建,所以只能靠"是否已是目录"这一条证据;新目录取父目录是保守估计。)
41
+ */
42
+ function directoryOf(target) {
43
+ try {
44
+ if (statSync(target).isDirectory()) return target
45
+ } catch {
46
+ /* 不存在/不可读 ⇒ 按"待创建的文件"处理 */
47
+ }
48
+ return dirname(target)
49
+ }
50
+
51
+ /** 把若干原始写目标归一化并折叠成 {target, dir} 对(去重、丢弃空路径)。 */
52
+ function toPairs(rawTargets, cwd) {
53
+ const pairs = []
54
+ const seen = new Set()
55
+ for (const raw of Array.isArray(rawTargets) ? rawTargets : []) {
56
+ const target = normalizePath(raw, cwd)
57
+ if (target.length === 0) continue
58
+ const dir = directoryOf(target)
59
+ if (dir.length === 0) continue
60
+ const key = `${target}\n${dir}`
61
+ if (seen.has(key)) continue
62
+ seen.add(key)
63
+ pairs.push({ target, dir })
64
+ }
65
+ return pairs
66
+ }
67
+
68
+ /**
69
+ * 从一次工具调用推导"用户批准后要学的目录"。
70
+ *
71
+ * 只认写目标:read 不触发升权;shell 用静态分析结果,判不出就**不猜**(返回空)。
72
+ * 工具/参数不可读时不抛(审批监听器里必须容忍任何异常)。
73
+ *
74
+ * @param toolName - 工具名(审批请求里的 request.toolName)。
75
+ * @param args - 该次调用的参数(pre-execute 按 callId 记忆下来的那份)。
76
+ * @param cwd - 相对路径解析基准(会话 cwd)。
77
+ * @returns { pairs: Array<{target: string, dir: string}>, reason?: string }
78
+ */
79
+ export function writableDirsOf(toolName, args, cwd) {
80
+ let effect
81
+ try {
82
+ effect = classifyTool(toolName, args)
83
+ } catch {
84
+ return { pairs: [], reason: 'unreadable-args' }
85
+ }
86
+ if (effect.kind === 'write') return { pairs: toPairs(effect.targets, cwd) }
87
+ if (effect.kind !== 'shell') return { pairs: [], reason: 'not-a-write' }
88
+ const base = effect.workdir.length > 0 ? effect.workdir : cwd
89
+ const analysis = analyzeCommand(effect.command, { shell: effect.shell, cwd: base })
90
+ if (analysis.unanalyzable !== null) return { pairs: [], reason: 'unanalyzable' }
91
+ return { pairs: toPairs(analysis.writes, base) }
92
+ }
93
+
94
+ /** 目录是否与受保护路径相关(自身、祖先或后代都算)——状态/日志文件与 DSH home 用它。 */
95
+ function isProtected(dir, protectedPaths) {
96
+ return protectedPaths.some((path) => underRoot(path, dir) || underRoot(dir, path))
97
+ }
98
+
99
+ /** 目录是否等于某个敏感根、或是敏感根的祖先(更深层目录允许)。 */
100
+ function isSensitive(dir, sensitiveRoots) {
101
+ return sensitiveRoots.some((root) => underRoot(root, dir))
102
+ }
103
+
104
+ /**
105
+ * 过滤候选目录:只保留"值得且可以自动加入白名单"的那些。
106
+ *
107
+ * 跳过原因(写进审计日志用):
108
+ * blacklist 目标或目录命中黑名单(黑名单优先级最高,加了也无效)
109
+ * no-need 目标在项目内 / 临时区(本来就直通,无需白名单)
110
+ * already-whitelisted 目录已被现有白名单覆盖
111
+ * protected 与插件状态/日志文件或 DSH home 相关(防"自动白名单自己改写自己")
112
+ * sensitive 目录是系统根/用户 home 自身或其祖先(防止一次批准放大成整个系统/home)
113
+ *
114
+ * @param pairs - writableDirsOf 的输出。
115
+ * @param options - { roots, protectedPaths, sensitiveRoots }。
116
+ * @returns { accepted: string[], skipped: Array<{dir, target, reason}> }
117
+ */
118
+ export function filterAutoWhitelistDirs(pairs, options = {}) {
119
+ const roots = options.roots ?? {}
120
+ const whitelist = Array.isArray(roots.whitelist) ? roots.whitelist : []
121
+ const protectedPaths = (Array.isArray(options.protectedPaths) ? options.protectedPaths : [])
122
+ .filter((path) => typeof path === 'string' && path.length > 0)
123
+ const sensitive = (Array.isArray(options.sensitiveRoots) ? options.sensitiveRoots : [])
124
+ .filter((path) => typeof path === 'string' && path.length > 0)
125
+ const accepted = []
126
+ const skipped = []
127
+ for (const pair of Array.isArray(pairs) ? pairs : []) {
128
+ const target = pair?.target
129
+ const dir = pair?.dir
130
+ if (typeof target !== 'string' || typeof dir !== 'string' || target.length === 0 || dir.length === 0) continue
131
+ const classification = classifyPath(target, roots)
132
+ if (classification === 'blacklist') {
133
+ skipped.push({ dir, target, reason: 'blacklist' })
134
+ continue
135
+ }
136
+ if (classification === 'project' || classification === 'temp') {
137
+ skipped.push({ dir, target, reason: 'no-need' })
138
+ continue
139
+ }
140
+ if (classifyPath(dir, roots) === 'blacklist') {
141
+ skipped.push({ dir, target, reason: 'blacklist' })
142
+ continue
143
+ }
144
+ if (whitelist.some((root) => underRoot(dir, root))) {
145
+ skipped.push({ dir, target, reason: 'already-whitelisted' })
146
+ continue
147
+ }
148
+ if (isProtected(dir, protectedPaths)) {
149
+ skipped.push({ dir, target, reason: 'protected' })
150
+ continue
151
+ }
152
+ if (isSensitive(dir, sensitive)) {
153
+ skipped.push({ dir, target, reason: 'sensitive' })
154
+ continue
155
+ }
156
+ if (!accepted.includes(dir)) accepted.push(dir)
157
+ }
158
+ return { accepted, skipped }
159
+ }
160
+
161
+ /** 读状态文件里的全部原始条目(其它平台的要原样保留;文件坏了就当没有)。 */
162
+ function readRawEntries(file) {
163
+ try {
164
+ const data = JSON.parse(readFileSync(file, 'utf8'))
165
+ return Array.isArray(data?.entries) ? data.entries : []
166
+ } catch {
167
+ return []
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 读取当前平台的自动白名单条目。
173
+ *
174
+ * 容错是硬要求:状态文件由用户手改/跨平台同步产生,坏了只记 error 日志、不能影响插件加载。
175
+ *
176
+ * @param file - 状态文件绝对路径。
177
+ * @param platform - 当前平台(默认 process.platform)。
178
+ * @returns { entries: Array<object>, error?: string }
179
+ */
180
+ export function loadAutoWhitelist(file, platform = process.platform) {
181
+ let raw
182
+ try {
183
+ raw = readFileSync(file, 'utf8')
184
+ } catch (error) {
185
+ if (error?.code === 'ENOENT') return { entries: [], error: undefined }
186
+ return { entries: [], error: `读取失败:${String(error)}` }
187
+ }
188
+ let data
189
+ try {
190
+ data = JSON.parse(raw)
191
+ } catch (error) {
192
+ return { entries: [], error: `JSON 解析失败:${String(error)}` }
193
+ }
194
+ const entries = []
195
+ const seen = new Set()
196
+ for (const item of Array.isArray(data?.entries) ? data.entries : []) {
197
+ if (item === null || typeof item !== 'object') continue
198
+ const itemPlatform = item.platform === undefined ? platform : item.platform
199
+ if (itemPlatform !== platform) continue
200
+ const path = typeof item.path === 'string' ? item.path.trim() : ''
201
+ // 相对路径(含跨平台同步过来的另一平台写法)一律忽略,且不写回原文件。
202
+ if (path.length === 0 || !isAbsolute(path) || seen.has(path)) continue
203
+ seen.add(path)
204
+ entries.push({ ...item, path, platform })
205
+ }
206
+ return { entries, error: undefined }
207
+ }
208
+
209
+ /**
210
+ * 原子写当前平台的自动白名单(保留其它平台条目,防容器/宿主双写互删)。
211
+ *
212
+ * @param file - 状态文件绝对路径。
213
+ * @param platform - 当前平台。
214
+ * @param entries - 当前平台的完整条目列表(调用方已按上限裁剪)。
215
+ * @returns { ok: true } | { ok: false, error: string }
216
+ */
217
+ export function saveAutoWhitelist(file, platform, entries) {
218
+ const current = Array.isArray(entries) ? entries : []
219
+ const others = readRawEntries(file).filter((item) => item?.platform !== undefined && item.platform !== platform)
220
+ const payload = { version: STATE_VERSION, entries: [...others, ...current] }
221
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
222
+ try {
223
+ mkdirSync(dirname(file), { recursive: true })
224
+ writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
225
+ renameSync(tmp, file)
226
+ return { ok: true }
227
+ } catch (error) {
228
+ return { ok: false, error: String(error) }
229
+ }
230
+ }
231
+
232
+ /** Windows 敏感根:优先取环境变量(盘符可能不是 C:)。 */
233
+ function windowsSensitiveRoots() {
234
+ const roots = []
235
+ for (const key of ['SystemRoot', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramData']) {
236
+ const value = process.env[key]
237
+ if (typeof value === 'string' && value.trim().length > 0) roots.push(value.trim())
238
+ }
239
+ roots.push('C:\\')
240
+ return roots
241
+ }
242
+
243
+ /**
244
+ * 敏感根列表:这些目录自身与其祖先不会被自动加入白名单,更深层目录可以。
245
+ * 用途是防止"批准了一个文件"被放大成"整个系统目录/home"的长期信任。
246
+ *
247
+ * @param options - { platform, home }(测试可注入)。
248
+ * @returns 归一化后的绝对路径列表。
249
+ */
250
+ export function sensitiveRoots(options = {}) {
251
+ const platform = options.platform ?? process.platform
252
+ const home = typeof options.home === 'string' && options.home.length > 0 ? options.home : homedir()
253
+ const raw = platform === 'win32' ? [...windowsSensitiveRoots(), home] : [...POSIX_SENSITIVE_ROOTS, home]
254
+ const roots = []
255
+ for (const path of raw) {
256
+ const normalized = normalizePath(path, home)
257
+ if (normalized.length > 0 && !roots.includes(normalized)) roots.push(normalized)
258
+ }
259
+ return roots
260
+ }
package/lib/index.mjs CHANGED
@@ -10,7 +10,8 @@
10
10
  // ② 模式未激活(预设身份不是 activationPreset,且父链上也找不到)⇒ pass
11
11
  // ③ 工具分派:pass ⇒ pass;read/write ⇒ 逐目标判定;shell ⇒ 先静态分析
12
12
  // ④ 合成:多目标最严者胜(任一 ask 则 ask)
13
- // ⑤ unanalyzable ⇒ 按 unanalyzable 策略:deny(默认,带改写指引)或 ask
13
+ // ⑤ unanalyzable ⇒ 先黑名单文本粗检(opaqueBlacklistScan),再按 unanalyzable 策略:
14
+ // pass(v2 默认,交给沙箱运行时拦写)/ ask / deny
14
15
  // ⑥ 审批通道检查:子代理 / 会话 approval 为 never ⇒ 就地把 ask 改写成 deny
15
16
  // (⑦ 与下游合成、⑧ failMode 兜底在 apply() 里做,见 Task 8)
16
17
 
@@ -18,6 +19,15 @@ import { appendFileSync, mkdirSync } from 'node:fs'
18
19
  import { homedir, tmpdir } from 'node:os'
19
20
  import { dirname, isAbsolute, join } from 'node:path'
20
21
  import { analyzeCommand } from './analyze.mjs'
22
+ import {
23
+ MAX_AUTO_ENTRIES,
24
+ expandHome,
25
+ filterAutoWhitelistDirs,
26
+ loadAutoWhitelist,
27
+ saveAutoWhitelist,
28
+ sensitiveRoots,
29
+ writableDirsOf,
30
+ } from './auto-whitelist.mjs'
21
31
  import { classifyTool } from './classify.mjs'
22
32
  import { combine, classifyPath, decideTarget, normalizePath, normalizeRoots, underRoot } from './rules.mjs'
23
33
 
@@ -29,8 +39,8 @@ const DEFAULT_LOG = join(homedir(), '.kimi-code', 'hooks', 'logs', 'path_rules.d
29
39
  /** 父链上溯的最大层数(防环 + 防病态深链)。 */
30
40
  const MAX_PARENT_HOPS = 8
31
41
 
32
- /** 允许的 unanalyzable 策略。 */
33
- const UNANALYZABLE_POLICIES = new Set(['deny', 'ask'])
42
+ /** 允许的 unanalyzable 策略:pass=交给沙箱(v2 默认)、ask、deny。 */
43
+ const UNANALYZABLE_POLICIES = new Set(['deny', 'ask', 'pass'])
34
44
 
35
45
  /** 允许的 failMode。 */
36
46
  const FAIL_MODES = new Set(['closed', 'open'])
@@ -56,11 +66,14 @@ function absolutePathList(value, field) {
56
66
  export function normalizeConfig(raw) {
57
67
  const c = raw ?? {}
58
68
  if (c.unanalyzable !== undefined && !UNANALYZABLE_POLICIES.has(c.unanalyzable)) {
59
- throw new Error(`dsh-path-rules: unanalyzable must be "deny" or "ask", got ${JSON.stringify(c.unanalyzable)}`)
69
+ throw new Error(`dsh-path-rules: unanalyzable must be "deny", "ask" or "pass", got ${JSON.stringify(c.unanalyzable)}`)
60
70
  }
61
71
  if (c.failMode !== undefined && !FAIL_MODES.has(c.failMode)) {
62
72
  throw new Error(`dsh-path-rules: failMode must be "closed" or "open", got ${JSON.stringify(c.failMode)}`)
63
73
  }
74
+ if (c.autoWhitelistFile !== undefined && (typeof c.autoWhitelistFile !== 'string' || c.autoWhitelistFile.trim().length === 0)) {
75
+ throw new Error('dsh-path-rules: autoWhitelistFile must be a non-empty string')
76
+ }
64
77
  const activationPreset = c.activationPreset === undefined ? 'path-rules' : c.activationPreset
65
78
  if (typeof activationPreset !== 'string' || activationPreset.trim().length === 0) {
66
79
  throw new Error('dsh-path-rules: activationPreset must be a non-empty string')
@@ -71,10 +84,14 @@ export function normalizeConfig(raw) {
71
84
  whitelist: absolutePathList(c.whitelist, 'whitelist'),
72
85
  blacklist: absolutePathList(c.blacklist, 'blacklist'),
73
86
  allowTempRoots: c.allowTempRoots !== false,
74
- unanalyzable: c.unanalyzable ?? 'deny',
87
+ unanalyzable: c.unanalyzable ?? 'pass',
75
88
  failMode: c.failMode ?? 'closed',
76
89
  dryRun: c.dryRun === true,
77
90
  modelFallback: c.modelFallback === true,
91
+ autoWhitelist: c.autoWhitelist === true,
92
+ autoWhitelistFile: typeof c.autoWhitelistFile === 'string' ? c.autoWhitelistFile.trim() : '',
93
+ preemptiveWhitelistEscalation: c.preemptiveWhitelistEscalation !== false,
94
+ opaqueBlacklistScan: c.opaqueBlacklistScan !== false,
78
95
  logPath: typeof c.logPath === 'string' && c.logPath.trim().length > 0 ? c.logPath.trim() : DEFAULT_LOG,
79
96
  }
80
97
  }
@@ -197,13 +214,43 @@ function escalationTargetFromArgs(args) {
197
214
  return undefined
198
215
  }
199
216
 
200
- /** 升权请求的目标是否命中白名单(文件按 realpath 后前缀;命令按粗粒度子串,沿用旧插件口径)。 */
201
- function escalationIsWhitelisted(target, config, session) {
217
+ /**
218
+ * 命令类升权请求是否允许自动放行(2026-10-18 严格化)。
219
+ *
220
+ * 旧口径只看"命令文本里是否出现白名单路径",`cp /white/f.txt /elsewhere/f.txt` 也会被静默批准
221
+ * (自动白名单让白名单动态变化后风险更大)。这里改为按静态分析出的**写目标**判定:
222
+ *
223
+ * @returns true = 全部写目标都在白名单/项目内/临时区,且至少一个命中白名单 ⇒ 可自动放行;
224
+ * false = 存在黑名单或项目外写目标 ⇒ 必须交给用户;
225
+ * undefined = 判不出(参数缺失/不可解析/没有写目标)⇒ 退回旧的子串口径兜底。
226
+ */
227
+ function commandEscalationIsWhitelisted(config, session, services, toolName, args) {
228
+ if (args === undefined) return undefined
229
+ const cwd = sessionCwdOf(session, services)
230
+ const derived = writableDirsOf(toolName, args, cwd)
231
+ if (derived.pairs.length === 0) return undefined
232
+ const roots = buildRoots(session, config, services)
233
+ let sawWhitelist = false
234
+ for (const pair of derived.pairs) {
235
+ const kind = classifyPath(pair.target, roots)
236
+ if (kind === 'blacklist' || kind === 'outside') return false
237
+ if (kind === 'whitelist') sawWhitelist = true
238
+ }
239
+ return sawWhitelist ? true : undefined
240
+ }
241
+
242
+ /**
243
+ * 升权请求的目标是否命中白名单(文件按 realpath 后前缀;命令优先按静态写目标严格判定,
244
+ * 判不出时再用粗粒度子串兜底)。
245
+ */
246
+ function escalationIsWhitelisted(target, config, session, commandCheck) {
202
247
  if (config.whitelist.length === 0) return false
203
248
  if (target.kind === 'file') {
204
249
  const resolved = normalizePath(target.value, session?.header?.cwd ?? process.cwd())
205
250
  return config.whitelist.some((root) => underRoot(resolved, root))
206
251
  }
252
+ const strict = typeof commandCheck === 'function' ? commandCheck() : undefined
253
+ if (strict !== undefined) return strict
207
254
  return config.whitelist.some((root) => target.value.includes(root))
208
255
  }
209
256
 
@@ -221,6 +268,38 @@ function sessionCwdOf(session, services) {
221
268
  return process.cwd()
222
269
  }
223
270
 
271
+ /**
272
+ * 判不出读写目标时的兜底决策(v2,2026-10-18)。
273
+ *
274
+ * 设计目标:路径规则档是 workspace-write 的**超集**,不能比它更严。因此默认 `pass`
275
+ * (把命令透传给沙箱,由 workspace-write 在 syscall 级拦写);`deny`/`ask` 仅作为配置保留。
276
+ *
277
+ * 黑名单的「读」沙箱不拦(沙箱只限制写),对判不出的命令只剩**命令文本粗检**这一道兜底:
278
+ * 命令文本里出现黑名单路径 ⇒ ask(`opaqueBlacklistScan` 可关)。它拦不住脚本内部/间接读取,
279
+ * 属文档化盲区(spec v2 §7)。
280
+ *
281
+ * @param effect - classifyTool 的 shell 结果(unanalyzable 只可能来自 shell)。
282
+ * @param unanalyzable - analyzeCommand 的 {reason, fragment}。
283
+ * @param config - normalizeConfig 的结果。
284
+ * @returns `{ kind: 'pass' } | { kind: 'ask'|'deny', code, reason }`。
285
+ */
286
+ function decideUnanalyzable(effect, unanalyzable, config) {
287
+ const guidance = ';也可改写成可静态判定的形式(显式绝对路径、限定的写目标,或拆分命令)'
288
+ if (config.opaqueBlacklistScan && config.blacklist.length > 0 && effect.kind === 'shell') {
289
+ const hit = config.blacklist.find((root) => root.length > 0 && effect.command.includes(root))
290
+ if (hit !== undefined) {
291
+ return { kind: 'ask', code: 'BLACKLIST_COARSE', reason: `命令文本包含黑名单路径(无法静态解析,粗粒度判定):${hit}` }
292
+ }
293
+ }
294
+ if (config.unanalyzable === 'ask') {
295
+ return { kind: 'ask', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason})${guidance}` }
296
+ }
297
+ if (config.unanalyzable === 'deny') {
298
+ return { kind: 'deny', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason}),已拒绝${guidance}` }
299
+ }
300
+ return { kind: 'pass' }
301
+ }
302
+
224
303
  /**
225
304
  * 判定一次工具调用(纯判定:不写日志、不触 IO、不改状态)。
226
305
  *
@@ -261,10 +340,9 @@ export function decideCall(exec, config, services) {
261
340
 
262
341
  let decision
263
342
  if (unanalyzable !== null) {
264
- const guidance = ';请改写成可静态判定的形式(显式绝对路径、限定的写目标,或拆分命令)'
265
- decision = config.unanalyzable === 'ask'
266
- ? { kind: 'ask', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason})${guidance}` }
267
- : { kind: 'deny', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason}),已拒绝${guidance}` }
343
+ decision = decideUnanalyzable(effect, unanalyzable, config)
344
+ // v2:默认策略是"交给沙箱"(workspace-write 的 syscall 级写拦截),不产生本插件的判定
345
+ if (decision.kind === 'pass') return pass()
268
346
  } else {
269
347
  // 任一 ask 优先(需要用户批准);否则只要有一个目标要"交沙箱审批"就整体透传(沙箱会拦、会弹框);
270
348
  // 全部 allow 才由我们直接放行。
@@ -306,8 +384,37 @@ function logLine(logPath, event, summary, detail) {
306
384
  }
307
385
  }
308
386
 
387
+ /**
388
+ * 解析 DSH home:优先用宿主提供的 `dshHomePath` 服务(尊重 `DSH_HOME`),
389
+ * 缺失时按 `$DSH_HOME` → `~/.dsh` 回退。用于定位自动白名单状态文件与保护路径。
390
+ */
391
+ function resolveDshHome(ctx) {
392
+ const homePath = ctx.get?.('dshHomePath')
393
+ if (typeof homePath === 'function') {
394
+ try {
395
+ const home = homePath()
396
+ if (typeof home === 'string' && home.length > 0) return home
397
+ } catch {
398
+ /* 服务不可用时走环境变量回退 */
399
+ }
400
+ }
401
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
402
+ return fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
403
+ }
404
+
405
+ /**
406
+ * 自动白名单状态文件路径:配置优先(支持 `~`),否则放在 `<dsh home>/path-rules/auto-whitelist.json`。
407
+ * 按平台分区写在文件内部(见 auto-whitelist.mjs),所以容器与宿主同步同一份文件也不会串味。
408
+ */
409
+ function resolveAutoWhitelistFile(config, ctx) {
410
+ if (config.autoWhitelistFile.length > 0) {
411
+ return normalizePath(expandHome(config.autoWhitelistFile, homedir()), process.cwd())
412
+ }
413
+ return normalizePath(join(resolveDshHome(ctx), 'path-rules', 'auto-whitelist.json'), process.cwd())
414
+ }
415
+
309
416
  /** 给模型看的规则说明;**只有本会话真的受该模式管辖时才注入**(否则模型会误判自己的权限档)。 */
310
- function renderRules(config, session, services) {
417
+ function renderRules(config, session, services, extras = {}) {
311
418
  if (session === undefined || session === null) return ''
312
419
  if (typeof services.presetOf !== 'function') return ''
313
420
  try {
@@ -316,15 +423,20 @@ function renderRules(config, session, services) {
316
423
  return ''
317
424
  }
318
425
  const cwd = typeof session?.header?.cwd === 'string' ? session.header.cwd : ''
426
+ const declaredWhitelist = Array.isArray(extras.declaredWhitelist) ? extras.declaredWhitelist : config.whitelist
427
+ const autoEntries = Array.isArray(extras.autoEntries) ? extras.autoEntries : []
319
428
  const lines = [
320
429
  '[path-rules] 当前权限模式:路径规则。',
321
430
  `会话工作区(可自由读写):${cwd.length > 0 ? cwd : '(未知)'}`,
322
431
  '工作区外:读取允许;写入需要用户审批(会弹工具审批框)。',
323
432
  ]
324
- if (config.whitelist.length > 0) lines.push(`白名单(可自由读写):${config.whitelist.join(', ')}`)
433
+ if (declaredWhitelist.length > 0) lines.push(`白名单(可自由读写):${declaredWhitelist.join(', ')}`)
434
+ if (autoEntries.length > 0) {
435
+ lines.push(`自动白名单(用户批准后自动学习,升权请求会被自动放行、不弹框):${autoEntries.map((entry) => entry.path).join(', ')}`)
436
+ }
325
437
  if (config.blacklist.length > 0) lines.push(`黑名单(读写都需要审批):${config.blacklist.join(', ')}`)
326
- lines.push('bash/pwsh:只读命令直接放行;写命令请写成可静态判定的形式(显式路径),判不出的命令会被拒绝。')
327
- lines.push('工作区外写入若先被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入;白名单内的目标会自动放行。')
438
+ lines.push('bash/pwsh:只读命令直接放行;写命令目标可静态判定时按项目内/白名单/黑名单判定;判不出的命令交给沙箱按「工作区内修改」档在运行时拦截写入(不再直接拒绝)。')
439
+ lines.push('白名单目录的写入(能静态判定写目标时)会自动升权、一次成功;其它工作区外写入若被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入。')
328
440
  return lines.join('\n')
329
441
  }
330
442
 
@@ -366,6 +478,58 @@ function compose(downstream, decision, config, log, exec) {
366
478
  return downstream
367
479
  }
368
480
 
481
+ /**
482
+ * 白名单写入的预置升权(v2 D13,2026-10-18 方案 B)。
483
+ *
484
+ * 背景:白名单目录位于沙箱可写根(会话工作区 + 临时区)之外,即便插件判定 allow,沙箱仍会先拒绝一次,
485
+ * 模型必须带 sandbox_permissions 重试、再由本插件自动放行 —— 多一次失败往返。
486
+ *
487
+ * 做法:对**静态可判定**且写目标全部合法(白名单/项目内/临时区)、至少一个命中白名单、且至少一个在沙箱
488
+ * 可写根之外的 bash/pwsh 调用,在 pre-execute 里给该次调用预置
489
+ * `sandbox_permissions: danger-full-access` + justification;随后 bash/pwsh 工具发起升权审批,
490
+ * 本插件的 approval/request 监听器按白名单自动 `allowed-once`(不弹框),实现"一次成功"。
491
+ *
492
+ * 安全边界:该调用最终执行的策略与"模型被拒后重试成功"完全相同(同样 danger-full-access、同样由
493
+ * commandEscalationIsWhitelisted 的静态判据放行),不新增放权面;静态分析漏判的风险与既有自动放行同级。
494
+ * 任何失败(参数不可写/校验异常)都只记日志、退回沙箱首拒链路,不改变判定结果。
495
+ *
496
+ * @returns 是否注入了升权参数。
497
+ */
498
+ function preemptWhitelistEscalation(exec, config, services, log) {
499
+ if (!config.preemptiveWhitelistEscalation || config.dryRun) return false
500
+ const effect = classifyTool(exec.name, exec.arguments)
501
+ if (effect.kind !== 'shell') return false
502
+ const args = exec.arguments
503
+ if (args === null || typeof args !== 'object') return false
504
+ // 模型自己带了升权参数(被拒后的重试)时尊重原请求,不覆盖
505
+ if (args.sandbox_permissions !== undefined || args.justification !== undefined) return false
506
+ const session = sessionOf(exec)
507
+ if (session === undefined || session === null) return false
508
+ const cwd = effect.workdir.length > 0 ? effect.workdir : sessionCwdOf(session, services)
509
+ const analysis = analyzeCommand(effect.command, { shell: effect.shell, cwd })
510
+ if (analysis.unanalyzable !== null || analysis.writes.length === 0) return false
511
+ const roots = buildRoots(session, config, services)
512
+ let sawWhitelist = false
513
+ let needsEscalation = false
514
+ for (const write of analysis.writes) {
515
+ const target = normalizePath(write, cwd)
516
+ const kind = classifyPath(target, roots)
517
+ // 黑名单/项目外写不预置:交给既有判定链(ask / 沙箱审批),避免绕过用户决策
518
+ if (kind === 'blacklist' || kind === 'outside') return false
519
+ if (kind === 'whitelist') sawWhitelist = true
520
+ if (!insideWritableRoots(target, roots)) needsEscalation = true
521
+ }
522
+ if (!sawWhitelist || !needsEscalation) return false
523
+ try {
524
+ args.sandbox_permissions = 'danger-full-access'
525
+ args.justification = `路径规则:白名单目录写入自动升权(${analysis.writes.slice(0, 3).join(', ')})`
526
+ } catch {
527
+ return false
528
+ }
529
+ log('allow', 'preempt-escalation', describeCall(exec))
530
+ return true
531
+ }
532
+
369
533
  /**
370
534
  * 插件入口:注册 pre-execute 钩子与规则说明注入。
371
535
  *
@@ -374,17 +538,36 @@ function compose(downstream, decision, config, log, exec) {
374
538
  */
375
539
  export function apply(ctx, rawConfig) {
376
540
  const base = normalizeConfig(rawConfig)
541
+ const declaredWhitelist = normalizeRoots(base.whitelist, process.cwd())
377
542
  const config = {
378
543
  ...base,
379
- whitelist: normalizeRoots(base.whitelist, process.cwd()),
544
+ whitelist: [...declaredWhitelist],
380
545
  blacklist: normalizeRoots(base.blacklist, process.cwd()),
381
546
  }
382
547
  const log = (event, summary, detail) => logLine(config.logPath, event, summary, detail)
383
- log('plugin-loaded', `enabled=${config.enabled}`, `activationPreset=${config.activationPreset} dryRun=${config.dryRun}`)
548
+ log('plugin-loaded', `enabled=${config.enabled} autoWhitelist=${config.autoWhitelist}`, `activationPreset=${config.activationPreset} dryRun=${config.dryRun}`)
384
549
  if (config.modelFallback) {
385
550
  log('error', 'warn:model-fallback-ignored', 'modelFallback 本期不改变判定行为(模型裁决路径需另立 spec)')
386
551
  }
387
552
 
553
+ // 自动白名单的状态文件与保护路径(只在开关打开时才读盘;关闭时零 IO、零行为变化)。
554
+ const autoWhitelistFile = resolveAutoWhitelistFile(config, ctx)
555
+ const dshHome = resolveDshHome(ctx)
556
+ const protectedPaths = [...new Set([autoWhitelistFile, config.logPath, dshHome])]
557
+ /** 当前进程内的自动白名单条目(与 config.whitelist 同步追加;持久化只写本平台分区)。 */
558
+ const autoEntries = []
559
+ if (config.autoWhitelist) {
560
+ const loaded = loadAutoWhitelist(autoWhitelistFile, process.platform)
561
+ if (loaded.error !== undefined) log('error', 'auto-whitelist-load', loaded.error)
562
+ for (const entry of loaded.entries) {
563
+ const normalized = normalizePath(entry.path, process.cwd())
564
+ if (normalized.length === 0 || autoEntries.some((item) => item.path === normalized)) continue
565
+ autoEntries.push({ ...entry, path: normalized })
566
+ if (!config.whitelist.includes(normalized)) config.whitelist.push(normalized)
567
+ }
568
+ if (autoEntries.length > 0) log('auto-whitelist', `loaded=${autoEntries.length}`, autoWhitelistFile)
569
+ }
570
+
388
571
  const services = {
389
572
  presetOf: (session) => ctx.get('permissionPresets')?.current?.(session),
390
573
  workspaceRootOf: (session) => ctx.get('sandboxPolicy')?.resolve?.({ session })?.workspaceRoot,
@@ -412,29 +595,100 @@ export function apply(ctx, rawConfig) {
412
595
  scope.systemPrompt.context({
413
596
  name: 'path-rules',
414
597
  order: 111,
415
- text: (context) => renderRules(config, context?.agent?.session, services),
598
+ text: (context) => renderRules(config, context?.agent?.session, services, { declaredWhitelist, autoEntries }),
416
599
  })
417
600
  })
418
601
 
602
+ /**
603
+ * 把一次"用户批准的沙箱升权调用"学进自动白名单。
604
+ *
605
+ * 只在 path-rules 档激活的会话里学(其它档不改变任何状态);任何异常都吞掉并记日志 ——
606
+ * 它只是顺手降低重复弹框,绝不能影响审批结果本身。
607
+ */
608
+ const learnFromApproval = (request) => {
609
+ const session = request.agent?.session
610
+ if (session === undefined || session === null) return
611
+ if (!isActive(session, config, services)) return
612
+ const args = recentCallArgs.get(request.callId)
613
+ if (args === undefined) {
614
+ log('auto-whitelist-skip', 'no-remembered-args', request.callId)
615
+ return
616
+ }
617
+ const cwd = sessionCwdOf(session, services)
618
+ const derived = writableDirsOf(request.toolName, args, cwd)
619
+ if (derived.pairs.length === 0) {
620
+ log('auto-whitelist-skip', derived.reason ?? 'no-write-target', request.callId)
621
+ return
622
+ }
623
+ const roots = buildRoots(session, config, services)
624
+ const { accepted, skipped } = filterAutoWhitelistDirs(derived.pairs, {
625
+ roots,
626
+ protectedPaths,
627
+ sensitiveRoots: sensitiveRoots(),
628
+ })
629
+ for (const item of skipped) log('auto-whitelist-skip', `${item.reason} ${item.dir}`, describeCall({ name: request.toolName, arguments: args }))
630
+ if (accepted.length === 0) return
631
+
632
+ const added = []
633
+ for (const dir of accepted) {
634
+ if (autoEntries.some((entry) => entry.path === dir)) continue
635
+ autoEntries.push({
636
+ path: dir,
637
+ platform: process.platform,
638
+ addedAt: new Date().toISOString(),
639
+ tool: String(request.toolName ?? ''),
640
+ callId: request.callId,
641
+ })
642
+ if (!config.whitelist.includes(dir)) config.whitelist.push(dir)
643
+ added.push(dir)
644
+ }
645
+ // 上限按"最旧淘汰":自动白名单只是便利,不能无限膨胀。
646
+ while (autoEntries.length > MAX_AUTO_ENTRIES) {
647
+ const dropped = autoEntries.shift()
648
+ const index = config.whitelist.indexOf(dropped.path)
649
+ if (index >= 0) config.whitelist.splice(index, 1)
650
+ }
651
+ if (added.length === 0) return
652
+ const saved = saveAutoWhitelist(autoWhitelistFile, process.platform, autoEntries)
653
+ if (!saved.ok) log('error', 'auto-whitelist-save', saved.error)
654
+ log('auto-whitelist', `added=${added.length}`, added.join(', '))
655
+ }
656
+
419
657
  // 白名单目标的沙箱升权请求自动放行(承接已退役的 sandbox-path-allowlist 能力):
420
658
  // 只作答 "escalate sandbox to " 开头的请求,且目标必须命中白名单;其余一律交给下游弹框。
659
+ // autoWhitelist 打开时再包一层:等用户的结论,只有 allowed-once(用户点了同意)才学习。
421
660
  ctx.on('approval/request', (request, next) => {
422
661
  if (typeof request?.callId !== 'string') return next()
423
662
  if (typeof request.reason !== 'string' || !request.reason.startsWith(SANDBOX_ESCALATION_PREFIX)) return next()
663
+
664
+ let target
424
665
  try {
425
666
  const remembered = recentCallArgs.get(request.callId)
426
667
  const session = request.agent?.session
427
668
  // remembered 优先:真实运行态里审批请求附带的 session 不暴露 events,无法按 callId 回查
428
669
  // 会话日志(实测 hasEvents=false),只有 pre-execute 时记下的参数可靠。
429
- const target = escalationTargetFromArgs(remembered) ?? escalationTargetOf(session, request.callId)
430
- if (target === undefined || !escalationIsWhitelisted(target, config, session)) return next()
431
- // remembered 只进审计日志,便于区分"走记忆命中"与"走会话日志兜底命中"
432
- log('allow', 'whitelist-escalation', `${request.callId} remembered=${remembered !== undefined} ${target.value}`)
433
- return 'allowed-once'
670
+ target = escalationTargetFromArgs(remembered) ?? escalationTargetOf(session, request.callId)
671
+ const commandCheck = () => commandEscalationIsWhitelisted(config, session, services, request.toolName, remembered)
672
+ if (target !== undefined && escalationIsWhitelisted(target, config, session, commandCheck)) {
673
+ // remembered 只进审计日志,便于区分"走记忆命中"与"走会话日志兜底命中"
674
+ log('allow', 'whitelist-escalation', `${request.callId} remembered=${remembered !== undefined} ${target.value}`)
675
+ return 'allowed-once'
676
+ }
434
677
  } catch (error) {
435
678
  log('error', 'approval-error', String(error))
436
679
  return next()
437
680
  }
681
+ if (target === undefined || !config.autoWhitelist || !config.enabled || config.dryRun) return next()
682
+ return Promise.resolve(next()).then((outcome) => {
683
+ if (outcome === 'allowed-once') {
684
+ try {
685
+ learnFromApproval(request)
686
+ } catch (error) {
687
+ log('error', 'auto-whitelist-error', String(error))
688
+ }
689
+ }
690
+ return outcome
691
+ })
438
692
  }, { prepend: true })
439
693
 
440
694
  ctx.on('tools/pre-execute', async (exec, next) => {
@@ -461,6 +715,14 @@ export function apply(ctx, rawConfig) {
461
715
  }
462
716
  return downstream
463
717
  }
718
+ // v2 D13:白名单写的预置升权(只对"插件判定 allow"的调用;注入失败只记日志,退回沙箱首拒链路)
719
+ if (decision.kind === 'allow') {
720
+ try {
721
+ preemptWhitelistEscalation(exec, config, services, log)
722
+ } catch (error) {
723
+ log('error', 'preempt-escalation-error', String(error))
724
+ }
725
+ }
464
726
  return compose(downstream, decision, config, log, exec)
465
727
  })
466
728
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@gavinc-cn/dsh-path-rules",
3
3
  "title": "路径规则守卫",
4
4
  "description": "第五权限模式「路径规则」的逐调用闸门:项目内可读写 / 项目外读允许·写审批 / 白名单直通 / 黑名单读写都审批 / bash·pwsh 只读放行;经 tools/pre-execute 返回 allow|ask|deny。",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "type": "module",
7
7
  "main": "./lib/index.mjs",
8
8
  "files": [
@@ -15,7 +15,7 @@
15
15
  "./locale/*.json": "./locale/*.json"
16
16
  },
17
17
  "scripts": {
18
- "test": "node --test test/rules.test.mjs test/classify.test.mjs test/analyze-lex.test.mjs test/analyze-bash.test.mjs test/analyze-pwsh.test.mjs test/decide.test.mjs test/plugin.test.mjs test/analyze-security.test.mjs"
18
+ "test": "node --test test/rules.test.mjs test/classify.test.mjs test/analyze-lex.test.mjs test/analyze-bash.test.mjs test/analyze-pwsh.test.mjs test/decide.test.mjs test/plugin.test.mjs test/analyze-security.test.mjs test/auto-whitelist.test.mjs"
19
19
  },
20
20
  "license": "MIT",
21
21
  "repository": {