@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/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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Path rules guard",
4
+ "description": "Read/write inside the session workspace; reads outside allowed, writes outside require approval; allowlist bypasses; denylist always asks."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "路径规则守卫",
4
+ "description": "项目内可读写;项目外读允许、写需审批;白名单直通;黑名单一律审批。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@gavinc-cn/dsh-path-rules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }