dsh-isolation-gate 0.3.1 → 0.4.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 +26 -5
- package/README.md +2 -1
- package/docs/release-0.4.0.md +56 -0
- package/docs/screenshots/02-group.png +0 -0
- package/docs/screenshots/04-audit.png +0 -0
- package/docs/screenshots/05-special-profile.png +0 -0
- package/docs/screenshots/README.md +11 -3
- package/lib/client.js +771 -33
- package/lib/index.js +87 -5
- package/lib/profiles.js +60 -16
- package/lib/store.js +81 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -44,10 +44,18 @@ import {
|
|
|
44
44
|
setGroupLevel,
|
|
45
45
|
setLevel,
|
|
46
46
|
} from './service.js';
|
|
47
|
-
import { clearAudit } from './audit.js';
|
|
47
|
+
import { clearAudit } from './audit.js';
|
|
48
48
|
import { collectTargets } from './toolgate.js';
|
|
49
|
-
import { decideWithProfiles, knownDirBindings, loadProfileState } from './profiles-runtime.js';
|
|
50
|
-
import {
|
|
49
|
+
import { decideWithProfiles, knownDirBindings, loadProfileState, __clearProfileCaches } from './profiles-runtime.js';
|
|
50
|
+
import {
|
|
51
|
+
DEFAULT_LEVEL,
|
|
52
|
+
readState,
|
|
53
|
+
writeState,
|
|
54
|
+
withProfile,
|
|
55
|
+
withoutProfile,
|
|
56
|
+
withSessionProfile,
|
|
57
|
+
} from './store.js';
|
|
58
|
+
import { isValidProfileName, readProfiles } from './profiles.js';
|
|
51
59
|
|
|
52
60
|
export const name = 'dsh-isolation-gate';
|
|
53
61
|
/** `tools` 是硬依赖:工具执行门没有它就不成立。 */
|
|
@@ -181,6 +189,7 @@ export function apply(ctx, config) {
|
|
|
181
189
|
function pickWithWindowsDialog() {
|
|
182
190
|
return new Promise((resolve, reject) => {
|
|
183
191
|
const script = [
|
|
192
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
|
184
193
|
'Add-Type -AssemblyName System.Windows.Forms;',
|
|
185
194
|
'$d = New-Object System.Windows.Forms.FolderBrowserDialog;',
|
|
186
195
|
"$d.Description = '选择要加入门禁的目录';",
|
|
@@ -219,6 +228,9 @@ export function apply(ctx, config) {
|
|
|
219
228
|
`GET ${BASE}/ping`,
|
|
220
229
|
`GET ${BASE}/state`,
|
|
221
230
|
`GET ${BASE}/profiles`,
|
|
231
|
+
`POST ${BASE}/profile/save`,
|
|
232
|
+
`POST ${BASE}/profile/delete`,
|
|
233
|
+
`POST ${BASE}/session-profile`,
|
|
222
234
|
`POST ${BASE}/level`,
|
|
223
235
|
`POST ${BASE}/roster`,
|
|
224
236
|
`POST ${BASE}/group-level`,
|
|
@@ -243,17 +255,87 @@ export function apply(ctx, config) {
|
|
|
243
255
|
{
|
|
244
256
|
method: 'GET',
|
|
245
257
|
path: `${BASE}/profiles`,
|
|
246
|
-
handler: async () => {
|
|
258
|
+
handler: async (_req, _res, url) => {
|
|
247
259
|
const state = loadProfileState(cfg.statePath)
|
|
260
|
+
const sid = url?.searchParams?.get('sessionId') ?? ''
|
|
248
261
|
const profiles = [...state.profiles.values()].map((profile) => ({
|
|
249
262
|
name: profile.name,
|
|
263
|
+
label: profile.label || profile.name,
|
|
250
264
|
mode: profile.mode,
|
|
251
265
|
allow: profile.allow,
|
|
252
266
|
network: profile.network,
|
|
253
267
|
unknownTool: profile.unknownTool,
|
|
268
|
+
builtin: profile.builtin === true,
|
|
254
269
|
isDefault: profile.name === state.defaultProfile,
|
|
255
270
|
}))
|
|
256
|
-
return {
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
defaultProfile: state.defaultProfile,
|
|
274
|
+
profiles,
|
|
275
|
+
sessionOverrides: state.sessionOverrides,
|
|
276
|
+
currentSessionProfile: sid ? state.sessionOverrides[sid] ?? null : null,
|
|
277
|
+
dirBindings: knownDirBindings(),
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
method: 'POST',
|
|
283
|
+
path: `${BASE}/profile/save`,
|
|
284
|
+
handler: async (req) => {
|
|
285
|
+
const body = await readJsonBody(req)
|
|
286
|
+
const checked = isValidProfileName(body.name)
|
|
287
|
+
if (!checked.ok) return { ok: false, error: checked.reason }
|
|
288
|
+
const allow = Array.isArray(body.allow) ? body.allow : []
|
|
289
|
+
const loaded = readState(cfg.statePath)
|
|
290
|
+
const next = withProfile(loaded.state, checked.name, {
|
|
291
|
+
label: body.label,
|
|
292
|
+
mode: body.mode,
|
|
293
|
+
allow,
|
|
294
|
+
network: body.network,
|
|
295
|
+
unknownTool: body.unknownTool,
|
|
296
|
+
})
|
|
297
|
+
if (body.asDefault === true) next.defaultProfile = checked.name
|
|
298
|
+
writeState(cfg.statePath, next)
|
|
299
|
+
__clearProfileCaches()
|
|
300
|
+
console.log(`[dsh-isolation-gate] 档「${checked.name}」已保存(${allow.length} 条清单)`)
|
|
301
|
+
return { ok: true, name: checked.name, report: report() }
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
method: 'POST',
|
|
306
|
+
path: `${BASE}/profile/delete`,
|
|
307
|
+
handler: async (req) => {
|
|
308
|
+
const body = await readJsonBody(req)
|
|
309
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
310
|
+
if (name === '') return { ok: false, error: '缺少档名' }
|
|
311
|
+
const loaded = readState(cfg.statePath)
|
|
312
|
+
const isBuiltin = loaded.state.profiles[name] === undefined && ['normal', 'strict', 'sealed'].includes(name)
|
|
313
|
+
if (isBuiltin) return { ok: false, error: '内置三档不能删(可以复制一份再改)' }
|
|
314
|
+
const next = withoutProfile(loaded.state, name)
|
|
315
|
+
writeState(cfg.statePath, next)
|
|
316
|
+
__clearProfileCaches()
|
|
317
|
+
console.log(`[dsh-isolation-gate] 档「${name}」已删除(挂在它上面的会话绑定一并清除)`)
|
|
318
|
+
return { ok: true, name, report: report() }
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
method: 'POST',
|
|
323
|
+
path: `${BASE}/session-profile`,
|
|
324
|
+
handler: async (req) => {
|
|
325
|
+
const body = await readJsonBody(req)
|
|
326
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
327
|
+
if (sessionId === '') return { ok: false, error: '缺少会话 id' }
|
|
328
|
+
const name = typeof body.profile === 'string' ? body.profile.trim() : ''
|
|
329
|
+
const loaded = readState(cfg.statePath)
|
|
330
|
+
if (name !== '') {
|
|
331
|
+
const profiles = readProfiles(loaded.state)
|
|
332
|
+
if (!profiles.profiles.has(name)) return { ok: false, error: `没有这个档:${name}` }
|
|
333
|
+
}
|
|
334
|
+
const next = withSessionProfile(loaded.state, sessionId, name)
|
|
335
|
+
writeState(cfg.statePath, next)
|
|
336
|
+
__clearProfileCaches()
|
|
337
|
+
console.log(`[dsh-isolation-gate] 会话 ${sessionId} → ${name === '' ? '不额外限制(只走全局名单)' : name}`)
|
|
338
|
+
return { ok: true, sessionId, profile: name === '' ? null : name, report: report() }
|
|
257
339
|
},
|
|
258
340
|
},
|
|
259
341
|
{
|
package/lib/profiles.js
CHANGED
|
@@ -90,11 +90,29 @@ export class ProfileError extends Error {}
|
|
|
90
90
|
* `.dsh-gate.json` 在**自己的子树内**填允许项(见 narrowProfile)。
|
|
91
91
|
*/
|
|
92
92
|
export const BUILTIN_PROFILES = {
|
|
93
|
-
normal: { mode: 'blocklist' },
|
|
94
|
-
strict: { mode: 'allowlist', allow: [] },
|
|
95
|
-
sealed: { mode: 'sealed', allow: [] },
|
|
93
|
+
normal: { mode: 'blocklist', label: '宽松' },
|
|
94
|
+
strict: { mode: 'allowlist', allow: [], label: '严格' },
|
|
95
|
+
sealed: { mode: 'sealed', allow: [], label: '全封' },
|
|
96
96
|
};
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* 2026-10-22:档从"写死的三个"改成**用户可编辑的模板**。
|
|
100
|
+
* · builtin 三档仍作为新建时的基准(clone 出来再改);
|
|
101
|
+
* · 自定义档可以随时改名/改条目/删除;
|
|
102
|
+
* · 会话只**挂**一个档,没挂 = 不额外限制(只走全局名单)。
|
|
103
|
+
*/
|
|
104
|
+
export const PROFILE_NAME_MAX = 40;
|
|
105
|
+
|
|
106
|
+
/** 档名的合法性:非空、≤40 字、不含路径分隔符与花括号(写进 JSON 当键用)。 */
|
|
107
|
+
export function isValidProfileName(name) {
|
|
108
|
+
if (typeof name !== 'string') return { ok: false, reason: '名字必须是字符串' };
|
|
109
|
+
const trimmed = name.trim();
|
|
110
|
+
if (!trimmed) return { ok: false, reason: '名字不能为空' };
|
|
111
|
+
if (trimmed.length > PROFILE_NAME_MAX) return { ok: false, reason: `名字最长 ${PROFILE_NAME_MAX} 字` };
|
|
112
|
+
if (/[\\/{}"']/.test(trimmed)) return { ok: false, reason: '名字不能含 \\ / { } " \'' };
|
|
113
|
+
return { ok: true, name: trimmed };
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
function asArray(value) {
|
|
99
117
|
return Array.isArray(value) ? value : [];
|
|
100
118
|
}
|
|
@@ -124,7 +142,9 @@ export function normalizeProfile(name, raw) {
|
|
|
124
142
|
const sealed = mode === 'sealed';
|
|
125
143
|
const network = sealed ? 'deny' : def.network === 'deny' ? 'deny' : 'allow';
|
|
126
144
|
const unknownTool = sealed || def.unknownTool === 'deny' ? 'deny' : 'allow';
|
|
127
|
-
|
|
145
|
+
const label = typeof def.label === 'string' && def.label.trim() ? def.label.trim() : name;
|
|
146
|
+
const builtin = BUILTIN_PROFILES[name] !== undefined;
|
|
147
|
+
return { name, label, mode, allow, network, unknownTool, builtin };
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
/**
|
|
@@ -134,24 +154,28 @@ export function normalizeProfile(name, raw) {
|
|
|
134
154
|
export function readProfiles(state) {
|
|
135
155
|
const src = state && typeof state === 'object' ? state : {};
|
|
136
156
|
const profiles = new Map();
|
|
157
|
+
// 内置三档永远在(作为新建模板的基准);存盘的档覆盖同名内置。
|
|
158
|
+
for (const [name, def] of Object.entries(BUILTIN_PROFILES)) {
|
|
159
|
+
profiles.set(name, normalizeProfile(name, def));
|
|
160
|
+
}
|
|
137
161
|
if (src.profiles && typeof src.profiles === 'object') {
|
|
138
162
|
for (const [name, def] of Object.entries(src.profiles)) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (profiles.size === 0) {
|
|
143
|
-
// v2 兼容:objects 直接当 blocklist 档;同时带上内置的 strict / sealed 模板档。
|
|
144
|
-
profiles.set('normal', normalizeProfile('normal', { mode: 'blocklist', objects: src.objects }));
|
|
145
|
-
for (const [name, def] of Object.entries(BUILTIN_PROFILES)) {
|
|
146
|
-
if (name !== 'normal') profiles.set(name, normalizeProfile(name, def));
|
|
163
|
+
const checked = isValidProfileName(name);
|
|
164
|
+
if (!checked.ok) continue;
|
|
165
|
+
profiles.set(checked.name, normalizeProfile(checked.name, def));
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
const defaultProfile =
|
|
150
169
|
typeof src.defaultProfile === 'string' && profiles.has(src.defaultProfile)
|
|
151
170
|
? src.defaultProfile
|
|
152
|
-
:
|
|
153
|
-
const sessionOverrides =
|
|
154
|
-
|
|
171
|
+
: 'normal';
|
|
172
|
+
const sessionOverrides = {};
|
|
173
|
+
if (src.sessionOverrides && typeof src.sessionOverrides === 'object') {
|
|
174
|
+
for (const [sid, name] of Object.entries(src.sessionOverrides)) {
|
|
175
|
+
if (typeof sid !== 'string' || !sid.trim()) continue;
|
|
176
|
+
if (typeof name === 'string' && profiles.has(name)) sessionOverrides[sid] = name;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
155
179
|
return { profiles, defaultProfile, sessionOverrides };
|
|
156
180
|
}
|
|
157
181
|
|
|
@@ -287,7 +311,27 @@ export function evaluateCall(profile, call) {
|
|
|
287
311
|
const tool = typeof call.tool === 'string' ? call.tool : '';
|
|
288
312
|
const targets = asArray(call.targets).filter((t) => t && typeof t.path === 'string');
|
|
289
313
|
|
|
290
|
-
|
|
314
|
+
// 2026-10-22:blocklist 档不再"一律放行" —— 它按**自己那份清单**判:
|
|
315
|
+
// 命中 deny ⇒ 拒;命中 read ⇒ 只许读;未命中 ⇒ 放行。
|
|
316
|
+
// (此前这里直接 return allowed:true,导致「宽松」模板里编的清单根本不生效。)
|
|
317
|
+
if (p.mode === 'blocklist') {
|
|
318
|
+
for (const t of targets) {
|
|
319
|
+
const hit = matchAllow(p, t.path);
|
|
320
|
+
if (!hit) continue;
|
|
321
|
+
if (hit.level === 'deny') {
|
|
322
|
+
return { allowed: false, reason: `「${p.label || p.name}」把这个目录设为禁:${hit.path}`, matched: hit.path };
|
|
323
|
+
}
|
|
324
|
+
const need = t.op === 'read' || t.op === 'traverse' ? 'read' : 'full';
|
|
325
|
+
if (LEVEL_RANK[hit.level] < LEVEL_RANK[need]) {
|
|
326
|
+
return {
|
|
327
|
+
allowed: false,
|
|
328
|
+
reason: `「${p.label || p.name}」只给 ${hit.path} 到 ${hit.level},本次需要 ${need}`,
|
|
329
|
+
matched: hit.path,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { allowed: true, reason: '未命中本档的禁/只读清单' };
|
|
334
|
+
}
|
|
291
335
|
|
|
292
336
|
// allowlist / sealed:有路径就必须落在 allow 内
|
|
293
337
|
if (targets.length > 0) {
|
package/lib/store.js
CHANGED
|
@@ -36,7 +36,7 @@ import { looksLikeAbsolutePath, normalizePathKey } from './roster.js';
|
|
|
36
36
|
export const DEFAULT_STATE_PATH = 'D:\\.agents\\门禁-授权.json';
|
|
37
37
|
|
|
38
38
|
/** 当前磁盘格式代。 */
|
|
39
|
-
export const STATE_VERSION =
|
|
39
|
+
export const STATE_VERSION = 3;
|
|
40
40
|
|
|
41
41
|
/** 三级状态,顺序即"由紧到松",UI 按此顺序排按钮。 */
|
|
42
42
|
export const LEVELS = ['deny', 'read', 'full'];
|
|
@@ -80,6 +80,8 @@ export function emptyState(now) {
|
|
|
80
80
|
updatedBy: 'unknown',
|
|
81
81
|
groups: {},
|
|
82
82
|
objects: {},
|
|
83
|
+
profiles: {},
|
|
84
|
+
sessionOverrides: {},
|
|
83
85
|
};
|
|
84
86
|
// 旧读法别名:只在内存里,不写盘。
|
|
85
87
|
Object.defineProperty(state, 'harnesses', {
|
|
@@ -164,6 +166,37 @@ function normalizeGroups(raw) {
|
|
|
164
166
|
* 把任意 JSON 收敛成合法状态(v1 → v2 迁移在这里)。
|
|
165
167
|
* 多出来的字段丢弃,缺的补默认;**不丢级别、不丢组名**。
|
|
166
168
|
*/
|
|
169
|
+
/** 档(模板)原样保留 —— 深归一化交给 profiles.js 的 normalizeProfile(单一真身)。 */
|
|
170
|
+
function copyProfiles(raw) {
|
|
171
|
+
const out = {};
|
|
172
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
173
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
174
|
+
if (typeof key !== 'string' || key.trim() === '') continue;
|
|
175
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
176
|
+
out[key.trim()] = {
|
|
177
|
+
label: typeof value.label === 'string' ? value.label : '',
|
|
178
|
+
mode: typeof value.mode === 'string' ? value.mode : 'sealed',
|
|
179
|
+
allow: Array.isArray(value.allow) ? value.allow.filter((e) => e && typeof e === 'object') : [],
|
|
180
|
+
network: value.network === 'deny' ? 'deny' : 'allow',
|
|
181
|
+
unknownTool: value.unknownTool === 'deny' ? 'deny' : 'allow',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 会话 → 档名 的绑定表(值是档名;空/非法丢弃)。 */
|
|
189
|
+
function copySessionOverrides(raw) {
|
|
190
|
+
const out = {};
|
|
191
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
192
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
193
|
+
if (typeof key !== 'string' || key.trim() === '') continue;
|
|
194
|
+
if (typeof value === 'string' && value.trim() !== '') out[key] = value.trim();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
export function normalizeState(raw) {
|
|
168
201
|
const base = emptyState();
|
|
169
202
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
@@ -210,6 +243,8 @@ export function normalizeState(raw) {
|
|
|
210
243
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : 'unknown',
|
|
211
244
|
groups,
|
|
212
245
|
objects,
|
|
246
|
+
profiles: copyProfiles(raw.profiles),
|
|
247
|
+
sessionOverrides: copySessionOverrides(raw.sessionOverrides),
|
|
213
248
|
};
|
|
214
249
|
// 迁移痕迹(只读,不写盘):供 UI 显示"旧格式已自动迁移"。
|
|
215
250
|
if (raw.version !== STATE_VERSION || (raw.objects === undefined && raw.harnesses !== undefined)) {
|
|
@@ -282,6 +317,8 @@ export function writeState(filePath, state, now) {
|
|
|
282
317
|
updatedBy: typeof state?.updatedBy === 'string' && state.updatedBy !== '' ? state.updatedBy : 'gui',
|
|
283
318
|
groups: normalized.groups,
|
|
284
319
|
objects: normalized.objects,
|
|
320
|
+
profiles: normalized.profiles,
|
|
321
|
+
sessionOverrides: normalized.sessionOverrides,
|
|
285
322
|
};
|
|
286
323
|
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
287
324
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -478,6 +515,49 @@ export function pruneState(state, rosterKeys, options = {}) {
|
|
|
478
515
|
}
|
|
479
516
|
|
|
480
517
|
/** 组表:名字 + 成员数(给 UI / CLI)。 */
|
|
518
|
+
/** 新增/覆盖一个档(模板)。深归一化仍由 profiles.js 负责。 */
|
|
519
|
+
export function withProfile(state, name, def, options = {}) {
|
|
520
|
+
const next = normalizeState(state);
|
|
521
|
+
const key = String(name ?? '').trim();
|
|
522
|
+
if (key === '') throw new Error('档名不能为空');
|
|
523
|
+
const src = def !== null && typeof def === 'object' ? def : {};
|
|
524
|
+
next.profiles[key] = {
|
|
525
|
+
label: typeof src.label === 'string' ? src.label : '',
|
|
526
|
+
mode: ['blocklist', 'allowlist', 'sealed'].includes(src.mode) ? src.mode : 'sealed',
|
|
527
|
+
allow: Array.isArray(src.allow) ? src.allow.filter((e) => e && typeof e === 'object') : [],
|
|
528
|
+
network: src.network === 'deny' ? 'deny' : 'allow',
|
|
529
|
+
unknownTool: src.unknownTool === 'deny' ? 'deny' : 'allow',
|
|
530
|
+
};
|
|
531
|
+
next.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
|
|
532
|
+
return next;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 删档,并把挂在它上面的会话绑定一起清掉(避免留下指向空档的绑定)。 */
|
|
536
|
+
export function withoutProfile(state, name, options = {}) {
|
|
537
|
+
const next = normalizeState(state);
|
|
538
|
+
const key = String(name ?? '').trim();
|
|
539
|
+
if (key !== '') {
|
|
540
|
+
delete next.profiles[key];
|
|
541
|
+
for (const [sid, bound] of Object.entries(next.sessionOverrides)) {
|
|
542
|
+
if (bound === key) delete next.sessionOverrides[sid];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
next.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
|
|
546
|
+
return next;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 给某个会话挂档;name 为空 = 取消挂载(回到"只走全局名单")。 */
|
|
550
|
+
export function withSessionProfile(state, sessionId, name, options = {}) {
|
|
551
|
+
const next = normalizeState(state);
|
|
552
|
+
const sid = String(sessionId ?? '').trim();
|
|
553
|
+
if (sid === '') throw new Error('缺少会话 id');
|
|
554
|
+
const key = typeof name === 'string' ? name.trim() : '';
|
|
555
|
+
if (key === '') delete next.sessionOverrides[sid];
|
|
556
|
+
else next.sessionOverrides[sid] = key;
|
|
557
|
+
next.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
|
|
558
|
+
return next;
|
|
559
|
+
}
|
|
560
|
+
|
|
481
561
|
export function groupsOf(state) {
|
|
482
562
|
const base = normalizeState(state);
|
|
483
563
|
const counts = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-isolation-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "门禁 · 目录访问管制(隔离区授权台):一屏管理「哪些目录受管、各自什么级别」,三级=禁/只读/可读写删;判定同时挂在受管辖通道(bin/gate.mjs)与宿主工具执行门(ctx.tools.guard)上,默认禁、名单缺失一律拒绝(fail-closed)、审计可查、agent 可自查;支持分组批量切、目录选择器与状态文件自动迁移。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|