@gavinc-cn/dsh-path-rules 0.1.1 → 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.
Files changed (3) hide show
  1. package/README.md +33 -6
  2. package/lib/index.mjs +105 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,8 +10,10 @@ 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`,见下文);
15
17
  - **自动白名单**(可选,`autoWhitelist: true`):用户在审批框里批准的沙箱升权写入目标,
16
18
  其目录会自动进入白名单(持久化),下次同目录的写入不再弹框。见下文。
17
19
 
@@ -30,8 +32,8 @@ DSH 第五权限模式「路径规则」的逐调用闸门:挂在 `tools/pre-e
30
32
  ```
31
33
  黑名单命中 → ask 白名单命中 → allow 项目内 → allow
32
34
  临时区(/tmp、os.tmpdir())→ allow 项目外读 → allow
33
- 项目外写 → ask
34
- shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
35
+ 项目外写 → ask(工作区外写交给沙箱升权)
36
+ shell 判不出 → 黑名单文本粗检(命中 ⇒ ask)→ 否则按 `unanalyzable` 策略(默认 pass,交给沙箱)
35
37
  ```
36
38
 
37
39
  `ask` 走框架标准审批流程(`ctx.approval.request`);会话审批通道为 `never`(子代理被框架钉成
@@ -49,11 +51,13 @@ shell 判不出 → unanalyzable 策略(默认 deny + 改写指引)
49
51
  whitelist: [] # 绝对路径;可读可写,压过"项目外",但被黑名单压过
50
52
  blacklist: [] # 绝对路径;读写都要审批,压过一切
51
53
  allowTempRoots: true # /tmp + os.tmpdir() 视为可写(与沙箱口径一致)
52
- unanalyzable: deny # deny | ask:shell 判不出时的兜底
54
+ unanalyzable: pass # deny | ask | pass:shell 判不出时的兜底(pass=交给沙箱,v2 默认)
53
55
  failMode: closed # closed | open:插件内部异常时的兜底
54
56
  dryRun: false # true = 只记日志不拦(灰度用)
55
57
  modelFallback: false # 预留字段:置 true 仅记一条 warn 日志,判定行为不变
56
58
  autoWhitelist: false # true = 用户批准的沙箱升权写入目标自动进白名单(默认关闭)
59
+ preemptiveWhitelistEscalation: true # 白名单写预置升权:一次成功、不弹框(v2 默认开)
60
+ opaqueBlacklistScan: true # 判不出命令的黑名单文本粗检(黑名单读的唯一兜底,v2 默认开)
57
61
  autoWhitelistFile: <dsh home>/path-rules/auto-whitelist.json # 自动白名单状态文件(可选)
58
62
  logPath: ~/.kimi-code/hooks/logs/path_rules.dsh.log
59
63
  ```
@@ -126,6 +130,21 @@ auto-whitelist-save / auto-whitelist-error`。
126
130
  模型需按错误提示带 `sandbox_permissions` + `justification` 重试;区别只在于重试的审批请求
127
131
  由插件自动放行、不再弹框。详见下文「已知盲区 8」。
128
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 的运行时拦截。
147
+
129
148
  ## 已知盲区(未覆盖,别当安全)
130
149
 
131
150
  1. **长驻进程自行写盘**:`bash` 的 `run_in_background`、`job_*` 起的进程在命令文本之外写文件。
@@ -147,6 +166,13 @@ auto-whitelist-save / auto-whitelist-error`。
147
166
  敏感根保护只挡系统根与 home 自身/祖先,不挡更深的目录。
148
167
  11. **命令判不出写目标时升权放行退回子串口径**:`bash`/`pwsh` 命令若静态分析判不出(`unanalyzable`、
149
168
  参数未记忆),仍按"命令文本包含白名单路径"粗粒度放行 —— 这是旧插件沿用口径,无法在不分析的前提下收紧。
169
+ 12. **判不出命令的黑名单「读」**:沙箱只拦写、不拦读,本插件对判不出的命令只剩"命令文本粗检"
170
+ (`opaqueBlacklistScan`);脚本内部/间接读取(如脚本文件里读黑名单路径)不可见。
171
+ 13. **判不出命令的白名单「写」**:无法预判写目标 ⇒ 仍走"沙箱首拒 → 升权重试";且升权审批因解析不出目标
172
+ 会弹用户框、`autoWhitelist` 也学不到(`writableDirsOf` 对 unanalyzable 返回空)。
173
+ 14. **预置升权(`preemptiveWhitelistEscalation`)使该次调用失去沙箱写兜底**:与既有"模型重试后插件自动
174
+ 放行"属同一信任级别(最终都是 danger-full-access),但仍依赖静态分析正确;可用
175
+ `preemptiveWhitelistEscalation: false` 关闭,退回"首拒 + 重试"链路。
150
176
 
151
177
  ## 测试
152
178
 
@@ -159,5 +185,6 @@ node --test test/rules.test.mjs test/classify.test.mjs test/analyze-lex.test.mjs
159
185
 
160
186
  (Node v24 下 `node --test test/` 目录形式会失败,必须显式给出文件;`npm test` 已包含全部 9 个文件。)
161
187
 
162
- 设计文档:`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`;
163
190
  实现计划:`docs/superpowers/plans/2026-10-17-dsh-path-rules-permission-mode.md`。
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
 
@@ -38,8 +39,8 @@ const DEFAULT_LOG = join(homedir(), '.kimi-code', 'hooks', 'logs', 'path_rules.d
38
39
  /** 父链上溯的最大层数(防环 + 防病态深链)。 */
39
40
  const MAX_PARENT_HOPS = 8
40
41
 
41
- /** 允许的 unanalyzable 策略。 */
42
- const UNANALYZABLE_POLICIES = new Set(['deny', 'ask'])
42
+ /** 允许的 unanalyzable 策略:pass=交给沙箱(v2 默认)、ask、deny。 */
43
+ const UNANALYZABLE_POLICIES = new Set(['deny', 'ask', 'pass'])
43
44
 
44
45
  /** 允许的 failMode。 */
45
46
  const FAIL_MODES = new Set(['closed', 'open'])
@@ -65,7 +66,7 @@ function absolutePathList(value, field) {
65
66
  export function normalizeConfig(raw) {
66
67
  const c = raw ?? {}
67
68
  if (c.unanalyzable !== undefined && !UNANALYZABLE_POLICIES.has(c.unanalyzable)) {
68
- 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)}`)
69
70
  }
70
71
  if (c.failMode !== undefined && !FAIL_MODES.has(c.failMode)) {
71
72
  throw new Error(`dsh-path-rules: failMode must be "closed" or "open", got ${JSON.stringify(c.failMode)}`)
@@ -83,12 +84,14 @@ export function normalizeConfig(raw) {
83
84
  whitelist: absolutePathList(c.whitelist, 'whitelist'),
84
85
  blacklist: absolutePathList(c.blacklist, 'blacklist'),
85
86
  allowTempRoots: c.allowTempRoots !== false,
86
- unanalyzable: c.unanalyzable ?? 'deny',
87
+ unanalyzable: c.unanalyzable ?? 'pass',
87
88
  failMode: c.failMode ?? 'closed',
88
89
  dryRun: c.dryRun === true,
89
90
  modelFallback: c.modelFallback === true,
90
91
  autoWhitelist: c.autoWhitelist === true,
91
92
  autoWhitelistFile: typeof c.autoWhitelistFile === 'string' ? c.autoWhitelistFile.trim() : '',
93
+ preemptiveWhitelistEscalation: c.preemptiveWhitelistEscalation !== false,
94
+ opaqueBlacklistScan: c.opaqueBlacklistScan !== false,
92
95
  logPath: typeof c.logPath === 'string' && c.logPath.trim().length > 0 ? c.logPath.trim() : DEFAULT_LOG,
93
96
  }
94
97
  }
@@ -265,6 +268,38 @@ function sessionCwdOf(session, services) {
265
268
  return process.cwd()
266
269
  }
267
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
+
268
303
  /**
269
304
  * 判定一次工具调用(纯判定:不写日志、不触 IO、不改状态)。
270
305
  *
@@ -305,10 +340,9 @@ export function decideCall(exec, config, services) {
305
340
 
306
341
  let decision
307
342
  if (unanalyzable !== null) {
308
- const guidance = ';请改写成可静态判定的形式(显式绝对路径、限定的写目标,或拆分命令)'
309
- decision = config.unanalyzable === 'ask'
310
- ? { kind: 'ask', code: 'UNANALYZABLE', reason: `无法静态判定该命令的读写目标(${unanalyzable.reason})${guidance}` }
311
- : { 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()
312
346
  } else {
313
347
  // 任一 ask 优先(需要用户批准);否则只要有一个目标要"交沙箱审批"就整体透传(沙箱会拦、会弹框);
314
348
  // 全部 allow 才由我们直接放行。
@@ -401,8 +435,8 @@ function renderRules(config, session, services, extras = {}) {
401
435
  lines.push(`自动白名单(用户批准后自动学习,升权请求会被自动放行、不弹框):${autoEntries.map((entry) => entry.path).join(', ')}`)
402
436
  }
403
437
  if (config.blacklist.length > 0) lines.push(`黑名单(读写都需要审批):${config.blacklist.join(', ')}`)
404
- lines.push('bash/pwsh:只读命令直接放行;写命令请写成可静态判定的形式(显式路径),判不出的命令会被拒绝。')
405
- lines.push('工作区外写入若先被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入;白名单与自动白名单内的目标会自动放行。')
438
+ lines.push('bash/pwsh:只读命令直接放行;写命令目标可静态判定时按项目内/白名单/黑名单判定;判不出的命令交给沙箱按「工作区内修改」档在运行时拦截写入(不再直接拒绝)。')
439
+ lines.push('白名单目录的写入(能静态判定写目标时)会自动升权、一次成功;其它工作区外写入若被沙箱拒绝:用 sandbox_permissions(能覆盖该目标的最窄更宽模式)+ justification 重试同一调用,用户审批后即可写入。')
406
440
  return lines.join('\n')
407
441
  }
408
442
 
@@ -444,6 +478,58 @@ function compose(downstream, decision, config, log, exec) {
444
478
  return downstream
445
479
  }
446
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
+
447
533
  /**
448
534
  * 插件入口:注册 pre-execute 钩子与规则说明注入。
449
535
  *
@@ -629,6 +715,14 @@ export function apply(ctx, rawConfig) {
629
715
  }
630
716
  return downstream
631
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
+ }
632
726
  return compose(downstream, decision, config, log, exec)
633
727
  })
634
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.1",
5
+ "version": "0.1.2",
6
6
  "type": "module",
7
7
  "main": "./lib/index.mjs",
8
8
  "files": [