dsh-isolation-gate 0.0.0-stage → 0.2.0
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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.en.md +214 -0
- package/README.md +225 -2
- package/bin/gate.mjs +473 -0
- package/cordis.patch.yml +37 -0
- package/docs/screenshots/01-overview.png +0 -0
- package/docs/screenshots/README.md +9 -0
- package/lib/audit.js +92 -0
- package/lib/client.js +1329 -0
- package/lib/dirs.js +204 -0
- package/lib/guard.js +342 -0
- package/lib/index.js +417 -0
- package/lib/roster.js +355 -0
- package/lib/service.js +567 -0
- package/lib/store.js +509 -0
- package/lib/toolgate.js +372 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/dirs.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 门禁 · 名单 ↔ 磁盘 的差异计算(**只对名字类受管对象**)
|
|
3
|
+
*
|
|
4
|
+
* 🔴 纪律(任务卡 §五 红线,对本模块同样生效):
|
|
5
|
+
* 本模块**只碰目录名与存在性** —— 对隔离区**永不**读取目录内部内容、不 stat 子项、
|
|
6
|
+
* 不读文件。实现上只有两个动作:
|
|
7
|
+
* ① `readdirSync(root, { withFileTypes: true })` 取 **D 根一层**目录名(不递归);
|
|
8
|
+
* ② 对"符号链接/junction"这类 Dirent 判不出类型的条目,做**一次目录存在性判定**。
|
|
9
|
+
* ⇒ 面板能报出"名单里有、盘上没"和"盘上有、名单没",但**没有任何一个字节来自隔离区内部**。
|
|
10
|
+
*
|
|
11
|
+
* 注意:**路径类**受管对象不参与这份"盘根一层目录"比对(它们可以指向任意深度),
|
|
12
|
+
* 由 `service.buildReport` 单独做一次"目录存在 / 不存在"判定。
|
|
13
|
+
*
|
|
14
|
+
* 差异提示口径:
|
|
15
|
+
* · `roster-missing-dir` → 「已在名单但目录不存在」(疑似笔误 / 已删 / 盘未挂)
|
|
16
|
+
* · `dir-not-listed` → 「目录存在但未入名单」
|
|
17
|
+
* · `dir-not-listed-suspect` → 同上,且名字**疑似另一个 harness 的家**(命中名谱)
|
|
18
|
+
* · `ok` → 名单与磁盘一致
|
|
19
|
+
* · `case-mismatch` → 名字都在,但大小写不一致(名单 `Zcode` / 目录 `zcode`)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
23
|
+
|
|
24
|
+
/** 扫描的盘根默认值。 */
|
|
25
|
+
export const DEFAULT_ROOT = 'D:\\';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 疑似**受管目录**(多半是"别的 harness 的家")的名字谱(**只比名字,不读内容**)。
|
|
29
|
+
* 用途:把 D 根下"存在但未入名单"的目录里**最可能是新 harness**的那些挑出来置顶,
|
|
30
|
+
* 其余照实列在折叠区里由户主自己认。可经插件 config `harnessVocabulary` 覆盖/追加。
|
|
31
|
+
*/
|
|
32
|
+
export const HARNESS_VOCABULARY = [
|
|
33
|
+
'zcode',
|
|
34
|
+
'opencode',
|
|
35
|
+
'open-code',
|
|
36
|
+
'pi',
|
|
37
|
+
'codex',
|
|
38
|
+
'claude',
|
|
39
|
+
'claude-code',
|
|
40
|
+
'cursor',
|
|
41
|
+
'windsurf',
|
|
42
|
+
'trae',
|
|
43
|
+
'kiro',
|
|
44
|
+
'cline',
|
|
45
|
+
'roo',
|
|
46
|
+
'roo-code',
|
|
47
|
+
'aider',
|
|
48
|
+
'goose',
|
|
49
|
+
'amp',
|
|
50
|
+
'crush',
|
|
51
|
+
'gemini',
|
|
52
|
+
'gemini-cli',
|
|
53
|
+
'qwen',
|
|
54
|
+
'qwen-code',
|
|
55
|
+
'iflow',
|
|
56
|
+
'continue',
|
|
57
|
+
'copilot',
|
|
58
|
+
'bolt',
|
|
59
|
+
'devin',
|
|
60
|
+
'kilo',
|
|
61
|
+
'void',
|
|
62
|
+
'zed',
|
|
63
|
+
'augment',
|
|
64
|
+
'comate',
|
|
65
|
+
'tongyi',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** 列在"未入名单目录"里但没有信息量的系统条目(不隐藏、只是不置顶)。 */
|
|
69
|
+
export const SYSTEM_DIR_HINTS = [
|
|
70
|
+
'$recycle.bin',
|
|
71
|
+
'system volume information',
|
|
72
|
+
'node_modules',
|
|
73
|
+
'.pnpm-store',
|
|
74
|
+
'temp',
|
|
75
|
+
'tmp',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function normalizeRoot(root) {
|
|
79
|
+
const value = typeof root === 'string' && root.trim() !== '' ? root.trim() : DEFAULT_ROOT;
|
|
80
|
+
return value.endsWith('\\') || value.endsWith('/') ? value : `${value}\\`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 列出盘根下的**一层**目录名(不递归、不读内容)。
|
|
85
|
+
* @param {string} root 例如 `D:\`
|
|
86
|
+
* @returns {{ok:boolean, root:string, dirs:string[], error:string|null}}
|
|
87
|
+
*/
|
|
88
|
+
export function listRootDirs(root = DEFAULT_ROOT) {
|
|
89
|
+
const base = normalizeRoot(root);
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(base, { withFileTypes: true });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { ok: false, root: base, dirs: [], error: `无法列出 ${base}:${error?.message ?? String(error)}` };
|
|
95
|
+
}
|
|
96
|
+
const dirs = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
// 只取"是目录"或"是符号链接/junction 但指向目录"的条目 —— 目录名 + 存在性,仅此而已。
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
dirs.push(entry.name);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (entry.isSymbolicLink()) {
|
|
104
|
+
try {
|
|
105
|
+
if (statSync(`${base}${entry.name}`).isDirectory()) dirs.push(entry.name);
|
|
106
|
+
} catch {
|
|
107
|
+
/* 断链的 junction:不算目录 */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
dirs.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
|
112
|
+
return { ok: true, root: base, dirs, error: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hitVocabulary(name, vocabulary) {
|
|
116
|
+
const want = String(name ?? '').toLowerCase();
|
|
117
|
+
return (vocabulary ?? []).some((word) => String(word).toLowerCase() === want);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 计算差异表。
|
|
122
|
+
* @param {object} input
|
|
123
|
+
* @param {string[]} input.rosterNames 名单里的名字
|
|
124
|
+
* @param {string[]} input.rootDirs 盘根一层目录名
|
|
125
|
+
* @param {string[]} [input.vocabulary] 疑似受管目录名谱
|
|
126
|
+
*/
|
|
127
|
+
export function diffRoster(input) {
|
|
128
|
+
const rosterNames = Array.isArray(input?.rosterNames) ? input.rosterNames : [];
|
|
129
|
+
const rootDirs = Array.isArray(input?.rootDirs) ? input.rootDirs : [];
|
|
130
|
+
const vocabulary = Array.isArray(input?.vocabulary) && input.vocabulary.length > 0 ? input.vocabulary : HARNESS_VOCABULARY;
|
|
131
|
+
|
|
132
|
+
const byLower = new Map();
|
|
133
|
+
for (const dir of rootDirs) byLower.set(dir.toLowerCase(), dir);
|
|
134
|
+
|
|
135
|
+
const rows = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const name of rosterNames) {
|
|
138
|
+
const lower = name.toLowerCase();
|
|
139
|
+
seen.add(lower);
|
|
140
|
+
const actual = byLower.get(lower) ?? null;
|
|
141
|
+
let status = 'ok';
|
|
142
|
+
if (actual === null) status = 'roster-missing-dir';
|
|
143
|
+
else if (actual !== name) status = 'case-mismatch';
|
|
144
|
+
rows.push({
|
|
145
|
+
name,
|
|
146
|
+
dirName: actual,
|
|
147
|
+
source: 'roster',
|
|
148
|
+
inRoster: true,
|
|
149
|
+
dirExists: actual !== null,
|
|
150
|
+
suspect: hitVocabulary(name, vocabulary),
|
|
151
|
+
status,
|
|
152
|
+
hint:
|
|
153
|
+
status === 'roster-missing-dir'
|
|
154
|
+
? '已在名单但目录不存在(疑似笔误 / 已删 / 该盘未挂载)'
|
|
155
|
+
: status === 'case-mismatch'
|
|
156
|
+
? `名单写的「${name}」,盘上实际是「${actual}」——大小写不一致(不影响生效,Windows 不区分)`
|
|
157
|
+
: '在名单内 · 目录存在',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const unlisted = [];
|
|
162
|
+
for (const dir of rootDirs) {
|
|
163
|
+
const lower = dir.toLowerCase();
|
|
164
|
+
if (seen.has(lower)) continue;
|
|
165
|
+
const suspect = hitVocabulary(dir, vocabulary);
|
|
166
|
+
unlisted.push({
|
|
167
|
+
name: dir,
|
|
168
|
+
dirName: dir,
|
|
169
|
+
source: 'disk',
|
|
170
|
+
inRoster: false,
|
|
171
|
+
dirExists: true,
|
|
172
|
+
suspect,
|
|
173
|
+
status: suspect ? 'dir-not-listed-suspect' : 'dir-not-listed',
|
|
174
|
+
hint: suspect
|
|
175
|
+
? '目录存在但未入名单 —— 名字疑似受管目录(多半是另一个 harness 的家);**未入名单 ⇒ 当前不在受管辖范围内**'
|
|
176
|
+
: '目录存在但未入名单(非受管疑似的普通目录,可忽略)',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
unlisted.sort((a, b) => {
|
|
180
|
+
if (a.suspect !== b.suspect) return a.suspect ? -1 : 1;
|
|
181
|
+
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const rosterMissingDir = rows.filter((row) => row.status === 'roster-missing-dir').map((row) => row.name);
|
|
185
|
+
const dirNotListedSuspect = unlisted.filter((row) => row.suspect).map((row) => row.name);
|
|
186
|
+
const dirNotListed = unlisted.filter((row) => !row.suspect).map((row) => row.name);
|
|
187
|
+
const caseMismatch = rows.filter((row) => row.status === 'case-mismatch').map((row) => ({
|
|
188
|
+
roster: row.name,
|
|
189
|
+
disk: row.dirName,
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
rows,
|
|
194
|
+
unlisted,
|
|
195
|
+
summary: {
|
|
196
|
+
rosterCount: rosterNames.length,
|
|
197
|
+
rootDirCount: rootDirs.length,
|
|
198
|
+
rosterMissingDir,
|
|
199
|
+
dirNotListedSuspect,
|
|
200
|
+
dirNotListed,
|
|
201
|
+
caseMismatch,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
package/lib/guard.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 门禁 · 访问判定(三级 × 四操作)
|
|
3
|
+
*
|
|
4
|
+
* 这是全系统**唯一**决定"能不能进受管对象"的地方:GUI 开关、HTTP 路由、
|
|
5
|
+
* `bin/gate.mjs` 通道都调用这里的 `checkAccess`。
|
|
6
|
+
*
|
|
7
|
+
* 级别 可做的事
|
|
8
|
+
* deny(禁) 无 —— 默认
|
|
9
|
+
* read(只读) traverse(列目录名)· read(读文件)
|
|
10
|
+
* full(可读写删) 以上 + write(写/改)· delete(删)
|
|
11
|
+
*
|
|
12
|
+
* 判定顺序(给的是 path 时):
|
|
13
|
+
* ① 算**真身路径**(目标先 realpath,或"最深已存在祖先的 realpath + 剩余片段"),
|
|
14
|
+
* 所以 `D:\管家\某个指向 Zcode 的 junction\x` 也会被认成 `Zcode`,不能绕;
|
|
15
|
+
* ② 在名单的**路径对象**里找**最长前缀命中** —— 按**路径段**比较(大小写不敏感),
|
|
16
|
+
* 必须整段相等(`D:\a\b` 不会被 `D:\a\bc` 命中);命中多条取**最长**的那条;
|
|
17
|
+
* ③ 没命中路径对象 ⇒ 退回旧行为:取"盘根一级目录名",看它是否在**名字对象**里;
|
|
18
|
+
* ④ 都没命中 ⇒ `inRoster:false`,放行(语义不变:"不属本通道管辖")。
|
|
19
|
+
*
|
|
20
|
+
* 给的是 name 时:**只在名字对象里匹配**(大小写不敏感),行为与旧版一致。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **fail-closed**:名单不可用(`rosterOk === false`,含文件缺失 / 读坏)⇒ **一律拒绝**。
|
|
23
|
+
* 旧版在这种情况下会 `inRoster:false` ⇒ **放行一切**(fail-open,隔离会悄悄失效),
|
|
24
|
+
* 本版按"名单读不到就什么都不放"处理。
|
|
25
|
+
*
|
|
26
|
+
* 安全细节:判定失败(看不懂路径)⇒ **按拒绝**处理,不给"猜一下"留口子。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
30
|
+
import { dirname, resolve, win32 } from 'node:path';
|
|
31
|
+
import { LEVELS, LEVEL_LABEL, LEVEL_HINT, levelOf, normalizeLevel } from './store.js';
|
|
32
|
+
import { normalizePathKey, objectForName, sameObject } from './roster.js';
|
|
33
|
+
|
|
34
|
+
/** 操作 → 所需最低权限。 */
|
|
35
|
+
export const OPS = ['traverse', 'read', 'write', 'delete'];
|
|
36
|
+
|
|
37
|
+
export const OP_LABEL = {
|
|
38
|
+
traverse: '遍历(列目录名)',
|
|
39
|
+
read: '读取文件内容',
|
|
40
|
+
write: '写入 / 修改',
|
|
41
|
+
delete: '删除',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const OP_RANK = { traverse: 1, read: 2, write: 3, delete: 4 };
|
|
45
|
+
const LEVEL_RANK = { deny: 0, read: 2, full: 4 };
|
|
46
|
+
|
|
47
|
+
/** 该级别是否允许该操作。 */
|
|
48
|
+
export function levelAllows(level, op) {
|
|
49
|
+
const rank = OP_RANK[op];
|
|
50
|
+
if (rank === undefined) return false;
|
|
51
|
+
return LEVEL_RANK[normalizeLevel(level)] >= rank;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 该级别允许的操作清单(给 UI 与 agent 读)。 */
|
|
55
|
+
export function allowedOps(level) {
|
|
56
|
+
return OPS.filter((op) => levelAllows(level, op));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 三级一览(UI / CLI 共用同一份说明)。 */
|
|
60
|
+
export function levelTable() {
|
|
61
|
+
return LEVELS.map((level) => ({
|
|
62
|
+
level,
|
|
63
|
+
label: LEVEL_LABEL[level],
|
|
64
|
+
hint: LEVEL_HINT[level],
|
|
65
|
+
allowedOps: allowedOps(level),
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 取路径的**真身路径**与盘根一级目录名。
|
|
71
|
+
*
|
|
72
|
+
* 真身算法:找"最深的已存在祖先"再 realpath,**并把祖先之下的剩余片段接回去**
|
|
73
|
+
* —— 目标本身可能还不存在(例如准备新建),但拼出来的真身路径仍可用于前缀匹配。
|
|
74
|
+
*/
|
|
75
|
+
export function topSegmentOf(targetPath) {
|
|
76
|
+
if (typeof targetPath !== 'string' || targetPath.trim() === '') {
|
|
77
|
+
return { ok: false, error: '路径为空', top: null, realPath: null, root: null };
|
|
78
|
+
}
|
|
79
|
+
const absolute = resolve(targetPath);
|
|
80
|
+
let probe = absolute;
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (existsSync(probe)) break;
|
|
83
|
+
const parent = dirname(probe);
|
|
84
|
+
if (parent === probe) break;
|
|
85
|
+
probe = parent;
|
|
86
|
+
}
|
|
87
|
+
let real = probe;
|
|
88
|
+
let realAncestor = probe;
|
|
89
|
+
try {
|
|
90
|
+
realAncestor = realpathSync(probe);
|
|
91
|
+
} catch {
|
|
92
|
+
/* realpath 失败就退回字面路径;下面的判定不会因此放宽 */
|
|
93
|
+
}
|
|
94
|
+
const remainder = absolute.slice(probe.length);
|
|
95
|
+
real = `${realAncestor}${remainder}`;
|
|
96
|
+
const parsed = win32.parse(real);
|
|
97
|
+
const relative = real.slice(parsed.root.length);
|
|
98
|
+
const segment = relative.split(/[\\/]+/).filter(Boolean)[0] ?? null;
|
|
99
|
+
return {
|
|
100
|
+
ok: segment !== null,
|
|
101
|
+
top: segment,
|
|
102
|
+
realPath: real,
|
|
103
|
+
root: parsed.root,
|
|
104
|
+
error: segment === null ? `无法从路径解析出盘根一级目录名:${targetPath}` : null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 名字是否在名单内(大小写不敏感 · 只比名字)。 */
|
|
109
|
+
export function inRoster(rosterNames, name) {
|
|
110
|
+
const want = String(name ?? '').trim().toLowerCase();
|
|
111
|
+
if (want === '') return false;
|
|
112
|
+
return (rosterNames ?? []).some((n) => String(n).toLowerCase() === want);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 路径段切分(忽略大小写、忽略空段)。 */
|
|
116
|
+
function pathSegments(key) {
|
|
117
|
+
return String(key)
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.split(/[\\/]+/)
|
|
120
|
+
.filter((segment) => segment !== '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 前缀是否**按整段**命中目标(`D:\a\b` 命中 `D:\a\b\c`;不命中 `D:\a\bc`)。 */
|
|
124
|
+
export function isSegmentPrefix(prefixKey, targetKey) {
|
|
125
|
+
const a = pathSegments(prefixKey);
|
|
126
|
+
const b = pathSegments(targetKey);
|
|
127
|
+
if (a.length === 0 || a.length > b.length) return false;
|
|
128
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 在路径对象里找**最长前缀命中**(按路径段数最多者胜;同样多则取字面更长的)。
|
|
134
|
+
* @returns {{kind:'name'|'path',key:string,raw:string}|null}
|
|
135
|
+
*/
|
|
136
|
+
export function longestPrefixMatch(objects, targetPath) {
|
|
137
|
+
const targetKey = normalizePathKey(targetPath);
|
|
138
|
+
if (targetKey === null) return null;
|
|
139
|
+
let best = null;
|
|
140
|
+
let bestSegments = -1;
|
|
141
|
+
let bestLength = -1;
|
|
142
|
+
for (const object of Array.isArray(objects) ? objects : []) {
|
|
143
|
+
if (!object || object.kind !== 'path' || typeof object.key !== 'string') continue;
|
|
144
|
+
const key = normalizePathKey(object.key);
|
|
145
|
+
if (key === null || !isSegmentPrefix(key, targetKey)) continue;
|
|
146
|
+
const segments = pathSegments(key).length;
|
|
147
|
+
if (segments > bestSegments || (segments === bestSegments && key.length > bestLength)) {
|
|
148
|
+
best = { kind: 'path', key, raw: typeof object.raw === 'string' ? object.raw : key };
|
|
149
|
+
bestSegments = segments;
|
|
150
|
+
bestLength = key.length;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return best;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 名字对象命中(大小写不敏感)。 */
|
|
157
|
+
export function nameObjectMatch(objects, name) {
|
|
158
|
+
const candidate = objectForName(name);
|
|
159
|
+
if (!candidate) return null;
|
|
160
|
+
const hit = (Array.isArray(objects) ? objects : []).find(
|
|
161
|
+
(object) => object && object.kind === 'name' && sameObject(object, candidate),
|
|
162
|
+
);
|
|
163
|
+
return hit ? { kind: 'name', key: hit.key, raw: typeof hit.raw === 'string' ? hit.raw : hit.key } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 把调用方给的两套入参收敛成对象数组。
|
|
168
|
+
* 优先 `rosterObjects`(新的、两类都在);退化到旧的 `rosterNames`(只有名字)。
|
|
169
|
+
*/
|
|
170
|
+
function objectsFromInput(input) {
|
|
171
|
+
if (Array.isArray(input?.rosterObjects)) {
|
|
172
|
+
return input.rosterObjects.filter(
|
|
173
|
+
(object) => object && (object.kind === 'name' || object.kind === 'path') && typeof object.key === 'string',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(input?.rosterNames)) return input.rosterNames.map(objectForName).filter(Boolean);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function denyMessage(label, kind, level, op, channel) {
|
|
181
|
+
const kindLabel = kind === 'path' ? '受管路径' : '受管名字';
|
|
182
|
+
return (
|
|
183
|
+
`拒绝:${kindLabel}「${label}」当前授权级别为「${LEVEL_LABEL[level]}」(${level}),` +
|
|
184
|
+
`不允许「${OP_LABEL[op] ?? op}」。` +
|
|
185
|
+
(level === 'deny'
|
|
186
|
+
? '未明确授权一律按"默认禁"执行 —— 请在 GUI 侧边栏「门禁」开关里授权。'
|
|
187
|
+
: `该级别只允许:${allowedOps(level).map((o) => OP_LABEL[o] ?? o).join(' / ')}。`) +
|
|
188
|
+
`(管辖通道:${channel})`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 名单不可用 ⇒ 一律拒绝(fail-closed)。错误文案可直接抄进报告。 */
|
|
193
|
+
function rosterUnavailableDecision(input, op, channel) {
|
|
194
|
+
const where = typeof input?.rosterPath === 'string' && input.rosterPath !== '' ? input.rosterPath : '名单文件';
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
allowed: false,
|
|
198
|
+
op,
|
|
199
|
+
opLabel: OP_LABEL[op],
|
|
200
|
+
name: typeof input?.name === 'string' && input.name !== '' ? input.name : null,
|
|
201
|
+
target: typeof input?.path === 'string' && input.path !== '' ? input.path : null,
|
|
202
|
+
level: null,
|
|
203
|
+
levelLabel: null,
|
|
204
|
+
inRoster: null,
|
|
205
|
+
matchedKey: null,
|
|
206
|
+
matchedKind: null,
|
|
207
|
+
matchedLevel: null,
|
|
208
|
+
rosterUnavailable: true,
|
|
209
|
+
allowedOps: [],
|
|
210
|
+
channel,
|
|
211
|
+
error: null,
|
|
212
|
+
reason: `名单不可用(${where} 缺失或损坏)⇒ 按"一律拒绝"处理,请先恢复名单文件`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 授权判定(不碰任何文件内容)。
|
|
218
|
+
*
|
|
219
|
+
* @param {object} input
|
|
220
|
+
* @param {object} input.state 状态对象(store.readState().state)
|
|
221
|
+
* @param {Array<{kind,key,raw}>} [input.rosterObjects] 名单对象(两类都在;优先)
|
|
222
|
+
* @param {string[]} [input.rosterNames] 旧的"只有名字"入参(向后兼容)
|
|
223
|
+
* @param {boolean} [input.rosterOk] 名单是否读得到(`false` ⇒ 一律拒绝)
|
|
224
|
+
* @param {string} [input.rosterPath] 名单路径(只用于拒绝文案)
|
|
225
|
+
* @param {string} [input.name] 直接问某个受管名字
|
|
226
|
+
* @param {string} [input.path] 要访问的路径(与 name 二选一,优先 path)
|
|
227
|
+
* @param {string} input.op traverse | read | write | delete
|
|
228
|
+
* @param {string} [input.channel] 通道名(写进拒绝文案,便于追责)
|
|
229
|
+
*/
|
|
230
|
+
export function checkAccess(input) {
|
|
231
|
+
const channel = typeof input?.channel === 'string' && input.channel !== '' ? input.channel : 'dsh-isolation-gate';
|
|
232
|
+
const op = input?.op;
|
|
233
|
+
if (!OPS.includes(op)) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
allowed: false,
|
|
237
|
+
op: op ?? null,
|
|
238
|
+
error: `操作名不合法:${String(op)}(只接受 ${OPS.join(' / ')})`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// —— fail-closed:名单不可用 ⇒ 一律拒绝(不再是"放行一切")——
|
|
243
|
+
if (input?.rosterOk === false) return rosterUnavailableDecision(input, op, channel);
|
|
244
|
+
|
|
245
|
+
const objects = objectsFromInput(input);
|
|
246
|
+
const nameObjects = objects.filter((object) => object.kind === 'name');
|
|
247
|
+
const pathObjects = objects.filter((object) => object.kind === 'path');
|
|
248
|
+
|
|
249
|
+
let name = null;
|
|
250
|
+
let realPath = null;
|
|
251
|
+
let matched = null;
|
|
252
|
+
|
|
253
|
+
if (typeof input?.path === 'string' && input.path.trim() !== '') {
|
|
254
|
+
const resolved = topSegmentOf(input.path);
|
|
255
|
+
realPath = resolved.realPath ?? null;
|
|
256
|
+
name = resolved.ok ? resolved.top : null;
|
|
257
|
+
// ① 路径对象:最长前缀命中(前缀可以比"盘根一级"更深,也可以正好是盘根)
|
|
258
|
+
matched = longestPrefixMatch(pathObjects, realPath ?? input.path);
|
|
259
|
+
// ② 没命中路径对象 ⇒ 退回旧行为:盘根一级目录名 → 名字对象
|
|
260
|
+
if (!matched && name !== null) matched = nameObjectMatch(nameObjects, name);
|
|
261
|
+
if (!matched && name === null) {
|
|
262
|
+
return { ok: false, allowed: false, op, error: resolved.error, target: input.path };
|
|
263
|
+
}
|
|
264
|
+
} else if (typeof input?.name === 'string' && input.name.trim() !== '') {
|
|
265
|
+
name = input.name.trim();
|
|
266
|
+
// 给名字时**只在名字对象里匹配** —— 名字键与路径键是两个键空间。
|
|
267
|
+
matched = nameObjectMatch(nameObjects, name);
|
|
268
|
+
} else {
|
|
269
|
+
return { ok: false, allowed: false, op, error: '需要给 name 或 path 之一' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!matched) {
|
|
273
|
+
// 不属任何受管对象 ⇒ 本通道不管,照实说明(不冒充"已授权")。
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
allowed: true,
|
|
277
|
+
op,
|
|
278
|
+
opLabel: OP_LABEL[op],
|
|
279
|
+
name,
|
|
280
|
+
target: realPath,
|
|
281
|
+
level: null,
|
|
282
|
+
levelLabel: null,
|
|
283
|
+
inRoster: false,
|
|
284
|
+
matchedKey: null,
|
|
285
|
+
matchedKind: null,
|
|
286
|
+
matchedLevel: null,
|
|
287
|
+
allowedOps: [],
|
|
288
|
+
reason:
|
|
289
|
+
name === null
|
|
290
|
+
? `「${input.path}」不属任何受管对象 ⇒ 本通道放行(本通道只管辖名单内的名字与路径)`
|
|
291
|
+
: `「${name}」不在名单内 ⇒ 不属受管对象,本通道放行(本通道只管辖名单内的名字与路径)`,
|
|
292
|
+
channel,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const matchedKey = matched.key;
|
|
297
|
+
const level = levelOf(input.state, matchedKey);
|
|
298
|
+
const allowed = levelAllows(level, op);
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
allowed,
|
|
302
|
+
op,
|
|
303
|
+
opLabel: OP_LABEL[op],
|
|
304
|
+
// 命中时 `name` 以**命中对象**为准(路径命中就报路径键),旧字段语义收敛为"受管对象名"。
|
|
305
|
+
name: matchedKey,
|
|
306
|
+
top: name,
|
|
307
|
+
target: realPath,
|
|
308
|
+
level,
|
|
309
|
+
levelLabel: LEVEL_LABEL[level],
|
|
310
|
+
inRoster: true,
|
|
311
|
+
matchedKey,
|
|
312
|
+
matchedKind: matched.kind,
|
|
313
|
+
matchedLevel: level,
|
|
314
|
+
allowedOps: allowedOps(level),
|
|
315
|
+
reason: allowed
|
|
316
|
+
? `允许:${matched.kind === 'path' ? '受管路径' : '受管名字'}「${matchedKey}」当前为「${LEVEL_LABEL[level]}」,含「${OP_LABEL[op]}」`
|
|
317
|
+
: denyMessage(matchedKey, matched.kind, level, op, channel),
|
|
318
|
+
channel,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 给 agent / 工具侧读的紧凑策略块(一句话一行,能直接塞进规则或提示词)。
|
|
324
|
+
* @param {object} state 状态对象
|
|
325
|
+
* @param {Array<{kind,key}>|string[]} rosterKeys 受管对象(或旧的名字数组)
|
|
326
|
+
*/
|
|
327
|
+
export function policyLines(state, rosterKeys) {
|
|
328
|
+
return (rosterKeys ?? [])
|
|
329
|
+
.map((item) => {
|
|
330
|
+
const object =
|
|
331
|
+
typeof item === 'string'
|
|
332
|
+
? { kind: 'name', key: item }
|
|
333
|
+
: item && typeof item === 'object'
|
|
334
|
+
? { kind: item.kind === 'path' ? 'path' : 'name', key: item.key }
|
|
335
|
+
: null;
|
|
336
|
+
if (!object || typeof object.key !== 'string') return null;
|
|
337
|
+
const level = levelOf(state, object.key);
|
|
338
|
+
const tag = object.kind === 'path' ? '[路径] ' : '';
|
|
339
|
+
return `${tag}${object.key} = ${level}(${LEVEL_LABEL[level]})· 允许:${allowedOps(level).join(',') || '无'}`;
|
|
340
|
+
})
|
|
341
|
+
.filter((line) => line !== null);
|
|
342
|
+
}
|