@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
package/lib/rules.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// dsh-path-rules 的路径语义层(纯函数,无插件依赖)。
|
|
2
|
+
//
|
|
3
|
+
// 设计要点(spec §5.1):
|
|
4
|
+
// * normalizePath 只对「存在的最深前缀」做 realpath,剩余段原样拼回:
|
|
5
|
+
// 既不放过"项目内软链指向项目外"的逃逸,也不要求目标本身已存在
|
|
6
|
+
// (write 创建新文件时目标通常还不存在)。
|
|
7
|
+
// * underRoot 是词法前缀判断:target 等于 root,或以 `root + sep` 开头;
|
|
8
|
+
// 同名前缀的兄弟目录(/proj/abc vs /proj/ab)不算在其下。
|
|
9
|
+
|
|
10
|
+
import { realpathSync } from 'node:fs'
|
|
11
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/** realpath 一个路径;不存在/无权限时返回 undefined(不抛)。 */
|
|
14
|
+
function tryRealpath(path) {
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync.native(path)
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 把任意路径写法归一化成可用于判定比较的绝对路径。
|
|
24
|
+
*
|
|
25
|
+
* 目标存在 ⇒ 直接 realpath;目标不存在 ⇒ 向上找到最深已存在祖先做 realpath,
|
|
26
|
+
* 再把中间的路径段原样拼回(这样符号链接目录下的新文件也能按真实位置判定)。
|
|
27
|
+
*
|
|
28
|
+
* @param raw - 原始路径(可为相对路径);空/空白返回空串。
|
|
29
|
+
* @param cwd - 相对路径的解析基准(会话工作目录)。
|
|
30
|
+
* @returns 归一化后的绝对路径。
|
|
31
|
+
*/
|
|
32
|
+
export function normalizePath(raw, cwd) {
|
|
33
|
+
const text = String(raw ?? '').trim()
|
|
34
|
+
if (text.length === 0) return ''
|
|
35
|
+
const absolute = isAbsolute(text) ? text : resolve(cwd ?? process.cwd(), text)
|
|
36
|
+
const real = tryRealpath(absolute)
|
|
37
|
+
if (real !== undefined) return real
|
|
38
|
+
const tail = []
|
|
39
|
+
let current = absolute
|
|
40
|
+
for (;;) {
|
|
41
|
+
const parent = dirname(current)
|
|
42
|
+
if (parent === current) return absolute
|
|
43
|
+
tail.unshift(basename(current))
|
|
44
|
+
const parentReal = tryRealpath(parent)
|
|
45
|
+
if (parentReal !== undefined) return join(parentReal, ...tail)
|
|
46
|
+
current = parent
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 判断 target 是否位于 root 之下(含相等)。
|
|
52
|
+
*
|
|
53
|
+
* @param target - 已归一化的目标路径。
|
|
54
|
+
* @param root - 已归一化的根路径。
|
|
55
|
+
* @returns 同名前缀的兄弟目录返回 false。
|
|
56
|
+
*/
|
|
57
|
+
export function underRoot(target, root) {
|
|
58
|
+
if (typeof target !== 'string' || typeof root !== 'string') return false
|
|
59
|
+
if (target.length === 0 || root.length === 0) return false
|
|
60
|
+
if (target === root) return true
|
|
61
|
+
const boundary = root.endsWith(sep) ? root : root + sep
|
|
62
|
+
return target.startsWith(boundary)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 把配置里的一串路径归一化成根列表:逐项 normalizePath、丢弃空白项、去重保序。
|
|
67
|
+
*
|
|
68
|
+
* @param paths - 配置里的绝对路径数组(可为空/非数组)。
|
|
69
|
+
* @param cwd - 相对路径的解析基准(正常配置要求绝对路径,此参数只作兜底)。
|
|
70
|
+
* @returns 归一化后的根列表。
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeRoots(paths, cwd) {
|
|
73
|
+
const list = Array.isArray(paths) ? paths : []
|
|
74
|
+
const roots = []
|
|
75
|
+
for (const item of list) {
|
|
76
|
+
const normalized = normalizePath(item, cwd)
|
|
77
|
+
if (normalized.length > 0 && !roots.includes(normalized)) roots.push(normalized)
|
|
78
|
+
}
|
|
79
|
+
return roots
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 目标是否命中根列表中的任一根(词法前缀,含相等)。 */
|
|
83
|
+
function matchesAnyRoot(target, list) {
|
|
84
|
+
return Array.isArray(list) && list.some((root) => underRoot(target, root))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 判定目标落在哪一类,顺序固定:黑名单 → 白名单 → 项目内 → 临时区 → 项目外。
|
|
89
|
+
* 「先命中者胜」是 spec §4.2 的硬约定:黑名单压过一切(含项目内与白名单)。
|
|
90
|
+
*
|
|
91
|
+
* @param target - 已归一化的目标路径。
|
|
92
|
+
* @param roots - { project, whitelist, blacklist, temp }。
|
|
93
|
+
* @returns 'blacklist' | 'whitelist' | 'project' | 'temp' | 'outside'。
|
|
94
|
+
*/
|
|
95
|
+
export function classifyPath(target, roots) {
|
|
96
|
+
const r = roots ?? {}
|
|
97
|
+
if (matchesAnyRoot(target, r.blacklist)) return 'blacklist'
|
|
98
|
+
if (matchesAnyRoot(target, r.whitelist)) return 'whitelist'
|
|
99
|
+
if (typeof r.project === 'string' && r.project.length > 0 && underRoot(target, r.project)) return 'project'
|
|
100
|
+
if (matchesAnyRoot(target, r.temp)) return 'temp'
|
|
101
|
+
return 'outside'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 单目标决策:黑名单 ⇒ ask;项目外且写 ⇒ ask;其余 ⇒ allow。
|
|
106
|
+
*
|
|
107
|
+
* @param target - 已归一化的目标路径。
|
|
108
|
+
* @param effect - 'read' | 'write'。
|
|
109
|
+
* @param roots - 同 classifyPath。
|
|
110
|
+
* @returns { kind: 'allow'|'ask', code?: ReasonCode, reason?: string }。
|
|
111
|
+
*/
|
|
112
|
+
export function decideTarget(target, effect, roots) {
|
|
113
|
+
const kind = classifyPath(target, roots)
|
|
114
|
+
if (kind === 'blacklist') {
|
|
115
|
+
return { kind: 'ask', code: 'BLACKLIST', reason: `目标命中黑名单:${target}` }
|
|
116
|
+
}
|
|
117
|
+
if (kind === 'outside' && effect === 'write') {
|
|
118
|
+
return { kind: 'ask', code: 'PROJECT_OUTSIDE_WRITE', reason: `项目外写入:${target}` }
|
|
119
|
+
}
|
|
120
|
+
return { kind: 'allow' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 多目标合成:最严者胜(任一 ask 则整体 ask)。
|
|
125
|
+
*
|
|
126
|
+
* @param decisions - decideTarget 的结果列表。
|
|
127
|
+
* @returns { kind: 'allow'|'ask', codes: ReasonCode[] }(codes 去重保序,仅收 ask 的)。
|
|
128
|
+
*/
|
|
129
|
+
export function combine(decisions) {
|
|
130
|
+
const list = Array.isArray(decisions) ? decisions : []
|
|
131
|
+
const codes = []
|
|
132
|
+
let ask = false
|
|
133
|
+
for (const decision of list) {
|
|
134
|
+
if (decision?.kind !== 'ask') continue
|
|
135
|
+
ask = true
|
|
136
|
+
if (decision.code !== undefined && !codes.includes(decision.code)) codes.push(decision.code)
|
|
137
|
+
}
|
|
138
|
+
return ask ? { kind: 'ask', codes } : { kind: 'allow', codes: [] }
|
|
139
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavinc-cn/dsh-path-rules",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"title": "路径规则守卫",
|
|
4
|
+
"description": "第五权限模式「路径规则」的逐调用闸门:项目内可读写 / 项目外读允许·写审批 / 白名单直通 / 黑名单读写都审批 / bash·pwsh 只读放行;经 tools/pre-execute 返回 allow|ask|deny。",
|
|
5
|
+
"version": "0.1.1",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"lib",
|
|
10
|
+
"locale"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./lib/index.mjs",
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./locale/*.json": "./locale/*.json"
|
|
16
|
+
},
|
|
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 test/auto-whitelist.test.mjs"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/gavinc-cn/dsh-plugins.git",
|
|
24
|
+
"directory": "packages/runtime/dsh-path-rules"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/gavinc-cn/dsh-plugins/tree/master/packages/runtime/dsh-path-rules#readme",
|
|
27
|
+
"bugs": "https://github.com/gavinc-cn/dsh-plugins/issues",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org",
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|