@gavinc-cn/dsh-path-rules 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +162 -2
- package/lib/analyze.mjs +1078 -0
- package/lib/auto-whitelist.mjs +260 -0
- package/lib/classify.mjs +72 -0
- package/lib/index.mjs +634 -0
- package/lib/rules.mjs +139 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -4
|
@@ -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/classify.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// dsh-path-rules 的工具映射层(纯函数):工具名 + 参数 → 读写意图与目标。
|
|
2
|
+
//
|
|
3
|
+
// 只处理 spec §5.2 表里的工具;**其余一切工具一律返回 {kind:'pass'}(透传)**:
|
|
4
|
+
// 未受控工具若自行写盘,本闸门覆盖不到,这是 spec §9-2 的已知盲区,
|
|
5
|
+
// 不能靠"猜一个读写意图"来假装覆盖。
|
|
6
|
+
|
|
7
|
+
/** 取 file_path 的读工具。 */
|
|
8
|
+
const READ_FILE_TOOLS = new Set(['read', 'read_image'])
|
|
9
|
+
/** 取 file_path 的写工具。 */
|
|
10
|
+
const WRITE_FILE_TOOLS = new Set(['write', 'edit'])
|
|
11
|
+
/** 取 path(可缺省为会话 cwd)的搜索工具。 */
|
|
12
|
+
const SEARCH_TOOLS = new Set(['glob', 'grep'])
|
|
13
|
+
/** bash 里 workdir 的候选参数名(bash 工具有 workdir,部分调用用 cwd)。 */
|
|
14
|
+
const WORKDIR_KEYS = ['workdir', 'cwd']
|
|
15
|
+
|
|
16
|
+
/** 非空字符串判定:类型不符或空白一律当"不可用"。 */
|
|
17
|
+
function nonEmptyString(value) {
|
|
18
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 统一的 pass 结果。 */
|
|
22
|
+
function pass() {
|
|
23
|
+
return { kind: 'pass' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 把一个工具调用映射成读写意图。
|
|
28
|
+
*
|
|
29
|
+
* @param name - 工具名(大小写不敏感)。
|
|
30
|
+
* @param args - 工具参数(工具自身会做 schema 校验,这里只做保守提取)。
|
|
31
|
+
* @returns {kind:'pass'}
|
|
32
|
+
* | {kind:'read'|'write', targets:string[]}
|
|
33
|
+
* | {kind:'shell', command:string, workdir:string, shell:'bash'|'pwsh'}
|
|
34
|
+
* `targets` 为空数组表示"没有显式路径"(搜索工具的 path 缺省 = 会话 cwd,由调用方补)。
|
|
35
|
+
*/
|
|
36
|
+
export function classifyTool(name, args) {
|
|
37
|
+
const tool = String(name ?? '').toLowerCase()
|
|
38
|
+
const a = args !== null && typeof args === 'object' ? args : {}
|
|
39
|
+
if (READ_FILE_TOOLS.has(tool) || WRITE_FILE_TOOLS.has(tool)) {
|
|
40
|
+
const target = nonEmptyString(a['file_path'])
|
|
41
|
+
if (target === undefined) return pass()
|
|
42
|
+
return { kind: READ_FILE_TOOLS.has(tool) ? 'read' : 'write', targets: [target] }
|
|
43
|
+
}
|
|
44
|
+
if (SEARCH_TOOLS.has(tool)) {
|
|
45
|
+
const raw = a['path']
|
|
46
|
+
if (raw !== undefined && nonEmptyString(raw) === undefined) return pass()
|
|
47
|
+
const target = nonEmptyString(raw)
|
|
48
|
+
return { kind: 'read', targets: target === undefined ? [] : [target] }
|
|
49
|
+
}
|
|
50
|
+
if (tool === 'str_replace_editor') {
|
|
51
|
+
const command = nonEmptyString(a['command'])
|
|
52
|
+
const target = nonEmptyString(a['path'])
|
|
53
|
+
if (command === undefined || target === undefined) return pass()
|
|
54
|
+
return { kind: command === 'view' ? 'read' : 'write', targets: [target] }
|
|
55
|
+
}
|
|
56
|
+
if (tool === 'bash' || tool === 'pwsh') {
|
|
57
|
+
const command = nonEmptyString(a['command'])
|
|
58
|
+
if (command === undefined) return pass()
|
|
59
|
+
let workdir = ''
|
|
60
|
+
if (tool === 'bash') {
|
|
61
|
+
for (const key of WORKDIR_KEYS) {
|
|
62
|
+
const value = nonEmptyString(a[key])
|
|
63
|
+
if (value !== undefined) {
|
|
64
|
+
workdir = value
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'shell', command, workdir, shell: tool === 'bash' ? 'bash' : 'pwsh' }
|
|
70
|
+
}
|
|
71
|
+
return pass()
|
|
72
|
+
}
|