dsh-isolation-gate 1.0.0 → 1.1.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-10-24
4
+
5
+ - 加固:`gate_request` 只接受已在名单里的对象;面板「批准」会复核对象仍在受管名单(防止批准出名单外的孤儿对象)。
6
+ **模型可以直接管门禁了。** 以前改门禁只能人工点面板或 shell 出去跑 `gate.mjs`;这一版把同一套能力注册成 7 个模型工具,像任务板那样「说需求 → 模型自己调」。
7
+
8
+ - **新增 7 个工具**:`gate_status`(全量状态)、`gate_check`(干跑判定)、`gate_add`(加对象,固定「禁」)、`gate_set_level`(改级别)、`gate_request`(申请放宽)、`gate_requests`(看待批)、`gate_log`(读审计)。
9
+ - **权限边界(刻意的不对称)**:查 / 新增 / 收紧随时可做;**放宽(→只读 / 可读写删)模型自己批不了** —— 没户主授权窗口时只生成一条待批申请。
10
+ - **待批队列**:新文件 `<状态目录>\requests.json`(v1),含 `requests[]` 与 `aiGrantUntil`;同目标同级别去重,最多 50 条。**不动已冻结的 state v3**。
11
+ - **新增 4 条路由**:`GET /requests`、`POST /requests-approve`、`POST /requests-reject`、`POST /grant`(户主在面板/本机窗口批准,模型工具里没有"批准"这个动作)。
12
+ - **零新依赖**:工具定义直接按宿主 `ToolDefinition` 形状注册,**没有引入 `@deepseek-ai/dsh-tools` peer**,躲开兼容性闸门。
13
+ - 测试:6 个套件全绿(路由数 16 → 20)。
14
+
3
15
  ## 1.0.0 — 2026-10-23
4
16
 
5
17
  **1.0 = 功能与格式冻结,不是「完美版」。** 承诺范围见 README 的「1.0 的稳定性承诺」:状态文件 v3、CLI 子命令与退出码、HTTP 路径、四条判定语义 —— 只增不破。
package/lib/client.js CHANGED
@@ -546,6 +546,121 @@ window.__ModuleLoader__.load({
546
546
  * 纯视图:`props.model` 为 null 时是首次加载态。
547
547
  * 自测直接调它,不需要 React 运行时。
548
548
  */
549
+ /**
550
+ * 待批申请 + 「授权 AI 放宽」。
551
+ * 模型(gate_* 工具)只能收紧;要放宽就得在这里点一下 —— 这是唯一的人批入口。
552
+ */
553
+ function GateRequestsCard(props) {
554
+ const [data, setData] = useState(null)
555
+ const [error, setError] = useState(null)
556
+ const [busy, setBusy] = useState(false)
557
+
558
+ const load = useCallback(async () => {
559
+ try {
560
+ setData(await call(`${BASE}/requests`))
561
+ setError(null)
562
+ } catch (e) {
563
+ setError((e && e.message) || String(e))
564
+ }
565
+ }, [])
566
+
567
+ useEffect(() => {
568
+ load()
569
+ }, [load, props.refreshKey])
570
+
571
+ const act = async (fn) => {
572
+ setBusy(true)
573
+ setError(null)
574
+ try {
575
+ await fn()
576
+ await load()
577
+ if (props.onChanged) props.onChanged()
578
+ } catch (e) {
579
+ setError((e && e.message) || String(e))
580
+ } finally {
581
+ setBusy(false)
582
+ }
583
+ }
584
+
585
+ const pending = (data && data.requests) || []
586
+ const grantActive = !!(data && data.grantActive)
587
+ const grantUntil = data && data.aiGrantUntil
588
+ const levelText = (lv) => ({ deny: '禁', read: '只读', full: '可读写删' }[lv] || lv)
589
+
590
+ if (pending.length === 0 && !grantActive) return null
591
+
592
+ return h(
593
+ 'div',
594
+ {
595
+ style: {
596
+ marginTop: '12px',
597
+ border: `1px solid ${pending.length ? C.warn : C.border}`,
598
+ background: pending.length ? 'rgba(251,191,36,0.06)' : C.card,
599
+ borderRadius: '10px',
600
+ padding: '10px 12px',
601
+ },
602
+ },
603
+ h(
604
+ 'div',
605
+ { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
606
+ h(
607
+ 'div',
608
+ { style: { fontSize: '13px', fontWeight: 700, color: pending.length ? C.warn : C.text } },
609
+ pending.length ? `待批申请 · ${pending.length} 条(模型想把权限放宽)` : 'AI 放宽授权进行中',
610
+ ),
611
+ h('span', { style: { flex: '1 1 auto' } }),
612
+ h(Button, {
613
+ label: grantActive
614
+ ? `撤销授权(至 ${new Date(grantUntil).toLocaleTimeString()})`
615
+ : '授权 AI 放宽 30 分钟',
616
+ disabled: busy,
617
+ onClick: () => act(() => post(`${BASE}/grant`, { minutes: grantActive ? 0 : 30 })),
618
+ }),
619
+ ),
620
+ error
621
+ ? h('div', { style: { color: C.bad, fontSize: '12px', marginTop: '6px' } }, `✗ ${error}`)
622
+ : null,
623
+ pending.map((item) =>
624
+ h(
625
+ 'div',
626
+ {
627
+ key: item.id,
628
+ style: {
629
+ display: 'flex',
630
+ alignItems: 'center',
631
+ gap: '8px',
632
+ flexWrap: 'wrap',
633
+ marginTop: '8px',
634
+ paddingTop: '8px',
635
+ borderTop: `1px solid ${C.border}`,
636
+ },
637
+ },
638
+ h(
639
+ 'div',
640
+ { style: { fontSize: '12px', color: C.text } },
641
+ `${item.target}:${item.fromLevel ? levelText(item.fromLevel) : '未入名单'} → ${levelText(item.level)}`,
642
+ ),
643
+ h(
644
+ 'div',
645
+ { style: { fontSize: '11px', color: C.dim, flex: '1 1 220px' } },
646
+ item.reason || '(没写理由)',
647
+ ),
648
+ h(Button, {
649
+ label: '批准',
650
+ primary: true,
651
+ disabled: busy,
652
+ onClick: () => act(() => post(`${BASE}/requests-approve`, { id: item.id })),
653
+ }),
654
+ h(Button, {
655
+ label: '驳回',
656
+ disabled: busy,
657
+ onClick: () => act(() => post(`${BASE}/requests-reject`, { id: item.id })),
658
+ }),
659
+ ),
660
+ ),
661
+ )
662
+ }
663
+
549
664
  function GateView(props) {
550
665
  const model = props.model
551
666
  const levels = (model && model.levels) || []
@@ -635,6 +750,9 @@ window.__ModuleLoader__.load({
635
750
  h(Button, { label: props.busy ? '刷新中…' : '刷新', onClick: props.onRefresh, disabled: props.busy }),
636
751
  ),
637
752
 
753
+ // —— 待批申请 / AI 放宽授权(模型工具的配套人批入口) ——
754
+ h(GateRequestsCard, { refreshKey: props.busy ? 1 : 0, onChanged: props.onRefresh }),
755
+
638
756
  // —— 摘要条 ——
639
757
  h(
640
758
  'div',
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 模型可直接调用的门禁工具(2026-10-24 新增)。
3
+ *
4
+ * 为什么要有:户主平时不点面板,只说需求。以前模型只能 shell 出去跑
5
+ * `node bin/gate.mjs ...`,多一层猜;这里把同一套能力注册成工具,
6
+ * 模型像调 `task_board_*` 一样直接调。
7
+ *
8
+ * 权限边界(户主拍板「只能收紧是对的,但用户授权后可以全做」):
9
+ * · 查:随时;
10
+ * · 新增对象:只能按默认 `deny` 加;
11
+ * · 收紧(改到更严的级别):随时;
12
+ * · 放宽:只有户主在面板点了「授权 AI 放宽 N 分钟」的窗口期内才直接生效,
13
+ * 否则写一条待批申请,等户主批准。模型自己不能批。
14
+ */
15
+ import { checkTool } from './service.js';
16
+ import { readAudit } from './audit.js';
17
+ import {
18
+ LEVELS,
19
+ LEVEL_LABEL,
20
+ readState,
21
+ writeState,
22
+ withLevel,
23
+ withGroup,
24
+ findEntryKey,
25
+ levelOf,
26
+ groupsOf,
27
+ } from './store.js';
28
+ import {
29
+ addRequest,
30
+ grantActive,
31
+ listRequests,
32
+ pendingCount,
33
+ readRequests,
34
+ requestsPathFor,
35
+ } from './requests.js';
36
+ import { normalizePathKey } from './roster.js';
37
+
38
+ const OP_LEVEL = { traverse: 0, read: 1, write: 2, delete: 3 };
39
+ const OPS = ['traverse', 'read', 'write', 'delete'];
40
+
41
+ function text(schema) {
42
+ return { type: 'string', description: schema };
43
+ }
44
+
45
+ /** 目标解析:名字(D 盘一级目录)或绝对路径。 */
46
+ function resolveTarget(args) {
47
+ if (typeof args.path === 'string' && args.path.trim()) {
48
+ return { target: normalizePathKey(args.path.trim()), kind: 'path' };
49
+ }
50
+ if (typeof args.name === 'string' && args.name.trim()) {
51
+ return { target: args.name.trim(), kind: 'name' };
52
+ }
53
+ throw new Error('必须给 name(D 盘一级目录名)或 path(绝对路径)之一');
54
+ }
55
+
56
+ function rank(level) {
57
+ const i = LEVELS.indexOf(level);
58
+ return i < 0 ? 0 : i;
59
+ }
60
+
61
+ function label(level) {
62
+ return LEVEL_LABEL[level] ?? level;
63
+ }
64
+
65
+ function targetExists(state, key) {
66
+ return findEntryKey(state, key) !== null;
67
+ }
68
+
69
+ function jsonResult(value) {
70
+ return {
71
+ schema: { type: 'object' },
72
+ render: (_args, v) => [{ type: 'text', text: JSON.stringify(v, null, 2) }],
73
+ };
74
+ }
75
+
76
+ /** 工具返回值必须是 lossless JSON:undefined / NaN / BigInt 会被宿主直接拒收。 */
77
+ function plain(value) {
78
+ return value === undefined ? null : JSON.parse(JSON.stringify(value));
79
+ }
80
+
81
+ export function registerGateTools(ctx, cfg) {
82
+ const requestsPath = requestsPathFor(cfg.statePath);
83
+ const register = (definition) => {
84
+ const run = definition.execute;
85
+ const safe = { ...definition, execute: async (args, exec) => plain(await run(args, exec)) };
86
+ return ctx.effect(() => ctx.tools.register(safe), `dsh-isolation-gate: ${definition.name}`);
87
+ };
88
+
89
+ // ── 1. 看全量状态 ────────────────────────────────────────────────
90
+ register({
91
+ name: 'gate_status',
92
+ description:
93
+ '查看门禁全量状态:默认级别、所有受管对象及其级别/分组、分组列表、待批申请数、AI 放宽授权是否有效。任何"门禁里现在是什么情况"的问题先调它。',
94
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
95
+ output: jsonResult(),
96
+ execute: async () => {
97
+ const state = readState(cfg.statePath).state;
98
+ const requests = listRequests(requestsPath);
99
+ const objects = Object.entries(state.objects ?? {}).map(([key, entry]) => ({
100
+ target: key,
101
+ level: entry.level,
102
+ levelLabel: label(entry.level),
103
+ group: entry.group || '',
104
+ note: entry.note || '',
105
+ }));
106
+ return {
107
+ defaultLevel: state.defaultLevel,
108
+ defaultLevelLabel: label(state.defaultLevel),
109
+ objects,
110
+ groups: groupsOf(state),
111
+ profiles: Object.keys(state.profiles ?? {}),
112
+ pendingRequests: requests.requests.length,
113
+ aiGrantUntil: requests.aiGrantUntil,
114
+ aiGrantActive: requests.grantActive,
115
+ auditPath: cfg.auditPath,
116
+ };
117
+ },
118
+ });
119
+
120
+ // ── 2. 干跑一次判定 ─────────────────────────────────────────────
121
+ register({
122
+ name: 'gate_check',
123
+ description:
124
+ '干跑判定:给一个路径(或目录名)和一个操作,返回门禁会不会放行、命中哪个对象、理由是什么。不读文件内容、不执行任何东西。动手前先问"这个能不能读/写"时用它。',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: {
128
+ name: text('D 盘一级目录名,如 Zcode'),
129
+ path: text('绝对路径,如 D:\\Zcode\\a.txt'),
130
+ op: { type: 'string', enum: OPS, description: '操作:traverse / read / write / delete' },
131
+ },
132
+ required: ['op'],
133
+ additionalProperties: false,
134
+ },
135
+ output: jsonResult(),
136
+ execute: async (args) => {
137
+ const { target, kind } = resolveTarget(args);
138
+ const op = String(args.op);
139
+ if (!OPS.includes(op)) throw new Error(`未知操作 ${op}`);
140
+ const decision = checkTool(cfg, kind === 'path' ? { path: target, op } : { name: target, op });
141
+ return {
142
+ target,
143
+ kind,
144
+ op,
145
+ allowed: decision.ok ? decision.allowed === true : false,
146
+ reason: decision.reason,
147
+ matched: decision.matchedKey ?? null,
148
+ level: decision.level ?? null,
149
+ inRoster: decision.inRoster ?? null,
150
+ error: decision.ok ? null : decision.reason,
151
+ };
152
+ },
153
+ });
154
+
155
+ // ── 3. 新增对象(只能默认禁)─────────────────────────────────────
156
+ register({
157
+ name: 'gate_add',
158
+ description:
159
+ '把目录(或绝对路径)加入受管名单,级别固定为「禁」。加进来只是"开始管它",不会放行任何东西;要只读/可写必须另外申请并由户主批准。',
160
+ parameters: {
161
+ type: 'object',
162
+ properties: {
163
+ name: text('D 盘一级目录名'),
164
+ path: text('绝对路径'),
165
+ group: text('可选:放进哪个组'),
166
+ note: text('可选:备注'),
167
+ },
168
+ additionalProperties: false,
169
+ },
170
+ output: jsonResult(),
171
+ execute: async (args) => {
172
+ const { target, kind } = resolveTarget(args);
173
+ const state = readState(cfg.statePath).state;
174
+ if (targetExists(state, target)) {
175
+ const current = levelOf(state, target);
176
+ return { ok: true, target, kind, level: current, changed: false, message: `已在名单里,当前「${label(current)}」` };
177
+ }
178
+ let next = withLevel(state, target, 'deny', { by: 'agent' });
179
+ if (args.group) next = withGroup(next, target, String(args.group), { by: 'agent' });
180
+ writeState(cfg.statePath, next);
181
+ return { ok: true, target, kind, level: 'deny', changed: true, message: '已加入名单,级别「禁」' };
182
+ },
183
+ });
184
+
185
+ // ── 4. 改级别(默认只允许收紧)──────────────────────────────────
186
+ register({
187
+ name: 'gate_set_level',
188
+ description:
189
+ '改一个受管对象的级别。收紧(往「禁」方向)随时可以;放宽(往「只读 / 可读写删」方向)只有在户主点了「授权 AI 放宽」的窗口期内才直接生效,否则会写一条待批申请并在返回里说明。',
190
+ parameters: {
191
+ type: 'object',
192
+ properties: {
193
+ name: text('D 盘一级目录名'),
194
+ path: text('绝对路径'),
195
+ level: { type: 'string', enum: LEVELS, description: 'deny / read / full' },
196
+ reason: text('可选:为什么要改,写进审计与待批申请'),
197
+ },
198
+ required: ['level'],
199
+ additionalProperties: false,
200
+ },
201
+ output: jsonResult(),
202
+ execute: async (args) => {
203
+ const { target, kind } = resolveTarget(args);
204
+ const want = String(args.level);
205
+ if (!LEVELS.includes(want)) throw new Error(`未知级别 ${want}`);
206
+ const state = readState(cfg.statePath).state;
207
+ if (!targetExists(state, target)) {
208
+ throw new Error(`「${target}」不在名单里;先用 gate_add 加进来(只会是「禁」)`);
209
+ }
210
+ const current = levelOf(state, target);
211
+ if (current === want) {
212
+ return { ok: true, changed: false, target, level: current, message: '已经是这个级别' };
213
+ }
214
+ const tightening = rank(want) < rank(current);
215
+ const requests = readRequests(requestsPath);
216
+ const mayWiden = grantActive(requests);
217
+ if (!tightening && !mayWiden) {
218
+ const request = addRequest(requestsPath, {
219
+ target,
220
+ targetKind: kind,
221
+ level: want,
222
+ fromLevel: current,
223
+ reason: args.reason ?? '',
224
+ requestedBy: 'agent',
225
+ });
226
+ return {
227
+ ok: false,
228
+ changed: false,
229
+ needsApproval: true,
230
+ requestId: request.id,
231
+ target,
232
+ from: current,
233
+ to: want,
234
+ message: `放宽需要户主批准:已在「门禁」面板生成待批申请 ${request.id}。户主点批准后即可生效;不要重复申请。`,
235
+ };
236
+ }
237
+ const next = withLevel(state, target, want, { by: 'agent' });
238
+ writeState(cfg.statePath, next);
239
+ return {
240
+ ok: true,
241
+ changed: true,
242
+ target,
243
+ from: current,
244
+ to: want,
245
+ viaGrant: !tightening && mayWiden,
246
+ message: tightening ? `已收紧到「${label(want)}」` : `已在授权窗口内放宽到「${label(want)}」`,
247
+ };
248
+ },
249
+ });
250
+
251
+ // ── 5. 申请放宽 ─────────────────────────────────────────────────
252
+ register({
253
+ name: 'gate_request',
254
+ description:
255
+ '为某个受管对象申请一个更宽的级别(只读 / 可读写删),写进待批列表等户主在面板批准。理由写清楚:为什么要、大概多久。',
256
+ parameters: {
257
+ type: 'object',
258
+ properties: {
259
+ name: text('D 盘一级目录名'),
260
+ path: text('绝对路径'),
261
+ level: { type: 'string', enum: LEVELS },
262
+ reason: text('为什么要放宽(户主看这条决定批不批)'),
263
+ },
264
+ required: ['level', 'reason'],
265
+ additionalProperties: false,
266
+ },
267
+ output: jsonResult(),
268
+ execute: async (args) => {
269
+ const { target, kind } = resolveTarget(args);
270
+ const want = String(args.level);
271
+ if (!LEVELS.includes(want)) throw new Error(`未知级别 ${want}`);
272
+ const state = readState(cfg.statePath).state;
273
+ const current = targetExists(state, target) ? levelOf(state, target) : null;
274
+ if (current === null) {
275
+ return {
276
+ ok: false,
277
+ error: `「${target}」还不在名单里。先用 gate_add 把它纳入受管(固定为「禁」),再申请放宽。`,
278
+ target,
279
+ kind,
280
+ hint: 'gate_add',
281
+ };
282
+ }
283
+ if (current === want) return { ok: true, created: false, message: '已经是这个级别,无需申请' };
284
+ const request = addRequest(requestsPath, {
285
+ target,
286
+ targetKind: kind,
287
+ level: want,
288
+ fromLevel: current,
289
+ reason: String(args.reason),
290
+ requestedBy: 'agent',
291
+ });
292
+ return {
293
+ ok: true,
294
+ created: true,
295
+ requestId: request.id,
296
+ target,
297
+ from: current,
298
+ to: want,
299
+ pending: pendingCount(requestsPath),
300
+ message: `已提交待批申请 ${request.id};户主批准前保持现状。`,
301
+ };
302
+ },
303
+ });
304
+
305
+ // ── 6. 看待批 ──────────────────────────────────────────────────
306
+ register({
307
+ name: 'gate_requests',
308
+ description: '列出门禁的待批申请(模型用来看自己提的申请有没有被批准)。',
309
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
310
+ output: jsonResult(),
311
+ execute: async () => listRequests(requestsPath),
312
+ });
313
+
314
+ // ── 7. 看审计 ──────────────────────────────────────────────────
315
+ register({
316
+ name: 'gate_log',
317
+ description: '读门禁最近的决定记录(时间 / 对象 / 级别 / 操作 / 是否放行 / 理由),不读文件内容。',
318
+ parameters: {
319
+ type: 'object',
320
+ properties: { limit: { type: 'integer', description: '条数,默认 20,最多 200' } },
321
+ additionalProperties: false,
322
+ },
323
+ output: jsonResult(),
324
+ execute: async (args) => {
325
+ const limit = Math.max(1, Math.min(200, Number(args.limit) || 20));
326
+ return { entries: readAudit(cfg.auditPath, limit) };
327
+ },
328
+ });
329
+
330
+ return { count: 7, requestsPath };
331
+ }
package/lib/index.js CHANGED
@@ -30,6 +30,8 @@
30
30
  */
31
31
 
32
32
  import { spawn } from 'node:child_process';
33
+ import { registerGateTools } from './gate-tools.js';
34
+ import { clearGrant, grantActive, listRequests, markRequest, requestsPathFor, setGrant, readRequests } from './requests.js';
33
35
  import {
34
36
  assignGroup,
35
37
  createGroup,
@@ -45,6 +47,7 @@ import {
45
47
  setLevel,
46
48
  } from './service.js';
47
49
  import { clearAudit } from './audit.js';
50
+ import { normalizePathKey } from './roster.js';
48
51
  import { collectTargets } from './toolgate.js';
49
52
  import { decideWithProfiles, knownDirBindings, loadProfileState, __clearProfileCaches } from './profiles-runtime.js';
50
53
  import {
@@ -61,7 +64,7 @@ export const name = 'dsh-isolation-gate';
61
64
  /** `tools` 是硬依赖:工具执行门没有它就不成立。 */
62
65
  export const inject = ['tools'];
63
66
 
64
- const VERSION = '0.3.0';
67
+ const VERSION = '1.1.0';
65
68
  const BASE = '/__dsh-isolation-gate';
66
69
  const MAX_BODY = 256 * 1024;
67
70
 
@@ -164,6 +167,16 @@ export function apply(ctx, config) {
164
167
  console.log('[dsh-isolation-gate] 工具执行门已关闭(config.toolGate=false,仅通道层生效)');
165
168
  }
166
169
 
170
+ // ---- 模型可直接调用的工具(2026-10-24)--------------------------------
171
+ // 户主不点面板、只说需求;模型用 gate_* 工具查/收紧/申请,放宽需户主批准。
172
+ try {
173
+ const info = registerGateTools(ctx, cfg);
174
+ console.log(`[dsh-isolation-gate] 已注册 ${info.count} 个模型工具(gate_*)`);
175
+ } catch (error) {
176
+ // 工具注册失败不能拖垮门禁本体(拦截仍然有效)。
177
+ console.log(`[dsh-isolation-gate] 工具注册失败:${error?.message ?? String(error)}`);
178
+ }
179
+
167
180
  // ---- 目录选择器(可选能力:优先宿主抽象服务,Windows 上自带原生兜底)--------
168
181
  // 契约:capability() 返回 `native`(pick)或 `browse`(list/createDirectory)之一。
169
182
  const pickerCapability = (() => {
@@ -241,6 +254,10 @@ export function apply(ctx, config) {
241
254
  `POST ${BASE}/check`,
242
255
  `GET ${BASE}/log`,
243
256
  `POST ${BASE}/audit/clear`,
257
+ `GET ${BASE}/requests`,
258
+ `POST ${BASE}/requests-approve`,
259
+ `POST ${BASE}/requests-reject`,
260
+ `POST ${BASE}/grant`,
244
261
  ],
245
262
  statePath: cfg.statePath,
246
263
  rosterPath: cfg.rosterPath,
@@ -532,6 +549,63 @@ export function apply(ctx, config) {
532
549
  }
533
550
  };
534
551
 
552
+ // ---- 待批申请 / AI 放宽授权(2026-10-24)------------------------------
553
+ // 批准与授权只走本机面板(HTTP),模型工具里没有"批准"这个动作。
554
+ routes.push(
555
+ {
556
+ method: 'GET',
557
+ path: `${BASE}/requests`,
558
+ handler: async () => {
559
+ const file = requestsPathFor(cfg.statePath);
560
+ return { ok: true, ...listRequests(file) };
561
+ },
562
+ },
563
+ {
564
+ method: 'POST',
565
+ path: `${BASE}/requests-approve`,
566
+ handler: async (req) => {
567
+ const body = await readJsonBody(req);
568
+ const file = requestsPathFor(cfg.statePath);
569
+ const request = readRequests(file).requests.find((item) => item.id === body.id);
570
+ if (!request || request.status !== 'pending') return { ok: false, error: '没有这条待批申请' };
571
+ const keys = Object.keys(readState(cfg.statePath).state.objects ?? {});
572
+ const stillManaged = keys.some((k) => normalizePathKey(k) === normalizePathKey(request.target));
573
+ if (!stillManaged) {
574
+ return { ok: false, error: `「${request.target}」已不在受管名单里,无法批准;请让模型重新 gate_add 后再申请。` };
575
+ }
576
+ const applied = setLevel(cfg, { name: request.target, level: request.level, by: 'owner' });
577
+ if (!applied.ok) return { ok: false, error: applied.error || '改级别失败' };
578
+ markRequest(file, request.id, 'approved');
579
+ return { ok: true, request, level: request.level };
580
+ },
581
+ },
582
+ {
583
+ method: 'POST',
584
+ path: `${BASE}/requests-reject`,
585
+ handler: async (req) => {
586
+ const body = await readJsonBody(req);
587
+ const file = requestsPathFor(cfg.statePath);
588
+ const rejected = markRequest(file, String(body.id), 'rejected');
589
+ return rejected ? { ok: true, request: rejected } : { ok: false, error: '没有这条待批申请' };
590
+ },
591
+ },
592
+ {
593
+ method: 'POST',
594
+ path: `${BASE}/grant`,
595
+ handler: async (req) => {
596
+ const body = await readJsonBody(req);
597
+ const file = requestsPathFor(cfg.statePath);
598
+ const minutes = Number(body.minutes);
599
+ if (minutes === 0) {
600
+ const cleared = clearGrant(file);
601
+ return { ok: true, aiGrantUntil: cleared.aiGrantUntil, grantActive: false };
602
+ }
603
+ const data = setGrant(file, minutes || 30);
604
+ return { ok: true, aiGrantUntil: data.aiGrantUntil, grantActive: grantActive(data) };
605
+ },
606
+ },
607
+ );
608
+
535
609
  const webServer = ctx.get('webServer');
536
610
  if (webServer !== undefined) {
537
611
  register(webServer, ctx);
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 待批申请 + AI 授权窗口(与状态文件分开存,避免动 1.0 冻结的 state v3 形状)。
3
+ *
4
+ * 设计约束(2026-10-24 户主拍板):
5
+ * · AI 默认只能收紧、只能新增(默认禁)、只能查;
6
+ * · 放宽(改成 read / full)走「申请 → 户主在面板点批准」;
7
+ * · 户主在面板点「授权 AI 放宽 N 分钟」后,窗口期内 AI 可以直接改,不必逐条批。
8
+ *
9
+ * 落点:与状态文件同目录的 `requests.json`。
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ export const REQUESTS_VERSION = 1;
15
+ export const DEFAULT_GRANT_MINUTES = 30;
16
+ export const MAX_PENDING = 50;
17
+
18
+ export function requestsPathFor(statePath) {
19
+ return join(dirname(statePath), 'requests.json');
20
+ }
21
+
22
+ function empty(now = new Date()) {
23
+ return {
24
+ version: REQUESTS_VERSION,
25
+ updatedAt: now.toISOString(),
26
+ aiGrantUntil: null,
27
+ requests: [],
28
+ };
29
+ }
30
+
31
+ function normalize(raw) {
32
+ if (!raw || typeof raw !== 'object') return empty();
33
+ const requests = Array.isArray(raw.requests) ? raw.requests : [];
34
+ return {
35
+ version: REQUESTS_VERSION,
36
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date().toISOString(),
37
+ aiGrantUntil: typeof raw.aiGrantUntil === 'string' ? raw.aiGrantUntil : null,
38
+ requests: requests
39
+ .filter((item) => item && typeof item.id === 'string' && typeof item.target === 'string')
40
+ .slice(0, MAX_PENDING),
41
+ };
42
+ }
43
+
44
+ export function readRequests(filePath) {
45
+ try {
46
+ if (!existsSync(filePath)) return empty();
47
+ return normalize(JSON.parse(readFileSync(filePath, 'utf8')));
48
+ } catch {
49
+ // 读坏 ⇒ 当作空;不影响门禁本体(门禁自己的 fail-closed 在状态文件那侧)。
50
+ return empty();
51
+ }
52
+ }
53
+
54
+ export function writeRequests(filePath, data, now = new Date()) {
55
+ const next = normalize({ ...data, updatedAt: now.toISOString() });
56
+ mkdirSync(dirname(filePath), { recursive: true });
57
+ const tmp = `${filePath}.${process.pid}.tmp`;
58
+ writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8');
59
+ renameSync(tmp, filePath);
60
+ return next;
61
+ }
62
+
63
+ export function grantActive(data, now = new Date()) {
64
+ if (!data || !data.aiGrantUntil) return false;
65
+ const until = Date.parse(data.aiGrantUntil);
66
+ return Number.isFinite(until) && until > now.getTime();
67
+ }
68
+
69
+ export function setGrant(filePath, minutes = DEFAULT_GRANT_MINUTES, now = new Date()) {
70
+ const data = readRequests(filePath);
71
+ const span = Math.max(1, Math.min(240, Number(minutes) || DEFAULT_GRANT_MINUTES));
72
+ data.aiGrantUntil = new Date(now.getTime() + span * 60_000).toISOString();
73
+ return writeRequests(filePath, data, now);
74
+ }
75
+
76
+ export function clearGrant(filePath, now = new Date()) {
77
+ const data = readRequests(filePath);
78
+ data.aiGrantUntil = null;
79
+ return writeRequests(filePath, data, now);
80
+ }
81
+
82
+ /** 待批:同目标 + 同级别只留一条(新的覆盖旧的),避免刷屏。 */
83
+ export function addRequest(filePath, input, now = new Date()) {
84
+ const data = readRequests(filePath);
85
+ const pending = data.requests.filter(
86
+ (item) => item.status === 'pending' && !(item.target === input.target && item.level === input.level),
87
+ );
88
+ const request = {
89
+ id: `req-${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
90
+ target: String(input.target),
91
+ targetKind: input.targetKind === 'path' ? 'path' : 'name',
92
+ level: input.level,
93
+ fromLevel: input.fromLevel ?? null,
94
+ reason: String(input.reason ?? '').slice(0, 500),
95
+ requestedBy: String(input.requestedBy ?? 'agent').slice(0, 80),
96
+ createdAt: now.toISOString(),
97
+ status: 'pending',
98
+ decidedAt: null,
99
+ };
100
+ data.requests = [request, ...pending].slice(0, MAX_PENDING);
101
+ writeRequests(filePath, data, now);
102
+ return request;
103
+ }
104
+
105
+ export function listRequests(filePath, options = {}) {
106
+ const data = readRequests(filePath);
107
+ const includeAll = options.includeAll === true;
108
+ return {
109
+ aiGrantUntil: data.aiGrantUntil,
110
+ grantActive: grantActive(data, options.now),
111
+ requests: includeAll ? data.requests : data.requests.filter((item) => item.status === 'pending'),
112
+ };
113
+ }
114
+
115
+ export function findRequest(filePath, id) {
116
+ const data = readRequests(filePath);
117
+ return data.requests.find((item) => item.id === id) ?? null;
118
+ }
119
+
120
+ export function markRequest(filePath, id, status, now = new Date()) {
121
+ const data = readRequests(filePath);
122
+ const index = data.requests.findIndex((item) => item.id === id);
123
+ if (index < 0) return null;
124
+ data.requests[index] = { ...data.requests[index], status, decidedAt: now.toISOString() };
125
+ writeRequests(filePath, data, now);
126
+ return data.requests[index];
127
+ }
128
+
129
+ export function pendingCount(filePath) {
130
+ return listRequests(filePath).requests.length;
131
+ }
package/package.json CHANGED
@@ -1,79 +1,79 @@
1
- {
2
- "name": "dsh-isolation-gate",
3
- "version": "1.0.0",
4
- "description": "门禁 · 目录访问管制(隔离区授权台):一屏管理「哪些目录受管、各自什么级别」,三级=禁/只读/可读写删;判定同时挂在受管辖通道(bin/gate.mjs)与宿主工具执行门(ctx.tools.guard)上,默认禁、名单缺失一律拒绝(fail-closed)、审计可查、agent 可自查;支持分组批量切、目录选择器与状态文件自动迁移。",
5
- "keywords": [
6
- "dsh",
7
- "dsh-plugin",
8
- "dsh-bundle",
9
- "isolation-gate",
10
- "permissions",
11
- "isolation",
12
- "authorization",
13
- "sandbox",
14
- "agent-safety",
15
- "access-control",
16
- "windows",
17
- "deepseek-harness",
18
- "cordis",
19
- "ai-agent",
20
- "security"
21
- ],
22
- "license": "MIT",
23
- "publishConfig": {
24
- "access": "public"
25
- },
26
- "type": "module",
27
- "main": "./lib/index.js",
28
- "exports": {
29
- ".": {
30
- "default": "./lib/index.js"
31
- },
32
- "./client": {
33
- "default": "./lib/client.js"
34
- },
35
- "./package.json": "./package.json",
36
- "./locale/*.json": "./locale/*.json"
37
- },
38
- "bin": {
39
- "gate": "bin/gate.mjs"
40
- },
41
- "files": [
42
- "lib",
43
- "bin",
44
- "locale",
45
- "cordis.patch.yml",
46
- "README.md",
47
- "README.en.md",
48
- "CHANGELOG.md",
49
- "LICENSE",
50
- "docs"
51
- ],
52
- "engines": {
53
- "node": ">=20.11"
54
- },
55
- "scripts": {
56
- "test": "node test/run-all.mjs",
57
- "gate": "node bin/gate.mjs",
58
- "release:check": "node scripts/release-sync.mjs"
59
- },
60
- "dsh": {
61
- "bundle": {
62
- "patch": "./cordis.patch.yml"
63
- },
64
- "client": {
65
- "inject": [
66
- "@deepseek-ai/dsh-client-runtime"
67
- ],
68
- "platform": "web"
69
- }
70
- },
71
- "repository": {
72
- "type": "git",
73
- "url": "git+https://github.com/huanlin-labs/dsh-isolation-gate.git"
74
- },
75
- "homepage": "https://github.com/huanlin-labs/dsh-isolation-gate#readme",
76
- "bugs": {
77
- "url": "https://github.com/huanlin-labs/dsh-isolation-gate/issues"
78
- }
79
- }
1
+ {
2
+ "name": "dsh-isolation-gate",
3
+ "version": "1.1.0",
4
+ "description": "门禁 · 目录访问管制(隔离区授权台):一屏管理「哪些目录受管、各自什么级别」,三级=禁/只读/可读写删;判定同时挂在受管辖通道(bin/gate.mjs)与宿主工具执行门(ctx.tools.guard)上,默认禁、名单缺失一律拒绝(fail-closed)、审计可查、agent 可自查;支持分组批量切、目录选择器与状态文件自动迁移。",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "dsh-bundle",
9
+ "isolation-gate",
10
+ "permissions",
11
+ "isolation",
12
+ "authorization",
13
+ "sandbox",
14
+ "agent-safety",
15
+ "access-control",
16
+ "windows",
17
+ "deepseek-harness",
18
+ "cordis",
19
+ "ai-agent",
20
+ "security"
21
+ ],
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./lib/index.js",
28
+ "exports": {
29
+ ".": {
30
+ "default": "./lib/index.js"
31
+ },
32
+ "./client": {
33
+ "default": "./lib/client.js"
34
+ },
35
+ "./package.json": "./package.json",
36
+ "./locale/*.json": "./locale/*.json"
37
+ },
38
+ "bin": {
39
+ "gate": "bin/gate.mjs"
40
+ },
41
+ "files": [
42
+ "lib",
43
+ "bin",
44
+ "locale",
45
+ "cordis.patch.yml",
46
+ "README.md",
47
+ "README.en.md",
48
+ "CHANGELOG.md",
49
+ "LICENSE",
50
+ "docs"
51
+ ],
52
+ "engines": {
53
+ "node": ">=20.11"
54
+ },
55
+ "scripts": {
56
+ "test": "node test/run-all.mjs",
57
+ "gate": "node bin/gate.mjs",
58
+ "release:check": "node scripts/release-sync.mjs"
59
+ },
60
+ "dsh": {
61
+ "bundle": {
62
+ "patch": "./cordis.patch.yml"
63
+ },
64
+ "client": {
65
+ "inject": [
66
+ "@deepseek-ai/dsh-client-runtime"
67
+ ],
68
+ "platform": "web"
69
+ }
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/huanlin-labs/dsh-isolation-gate.git"
74
+ },
75
+ "homepage": "https://github.com/huanlin-labs/dsh-isolation-gate#readme",
76
+ "bugs": {
77
+ "url": "https://github.com/huanlin-labs/dsh-isolation-gate/issues"
78
+ }
79
+ }