@twinsearth/w2m-dsh-plugin 1.7.8 → 1.8.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.7.8"
8
+ "version": "1.8.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.7.8",
15
+ "version": "1.8.0",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.7.8"
29
+ "version": "1.8.0"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.7.8",
4
+ "version": "1.8.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.8.0] — 2026-10-26
11
+
12
+ **GPW 增加四色审核 / 标记 / 提醒系统:绿(通过)、蓝(正常)、黄(警报)、红(风险)。**
13
+
14
+ - 新增 `src/plugin/gpw-colors.mjs`:把 DSH 各插件的审核行为归位到四色(只读/L1-L2/白名单命中/allow-list = 绿;
15
+ 本会话已批准过(指纹授权)= 蓝;越界写、非白名单命令、中风险 git/npm/网络操作、未知工具 = 黄;
16
+ 危险命令黑名单、敏感路径、系统路径、显式 deny 目录 = 红)。
17
+ - 门控改为颜色决策:绿/蓝直接放行(蓝来自会话内批准后的操作指纹);黄进入 GPW 待审批队列;红直接拒绝
18
+ (`tools.guard` + `tools/pre-execute` 双保险,拒绝理由带颜色语义)。
19
+ - 标记与提醒:`/api/w2m/gpw/state` 新增四色计数(colors)、待审批项与最近拦截项的颜色字段;
20
+ 面板显示四色图例与计数,待审批/最近条目带颜色标记;「允许本次会话」动作(allow-session)批准后,
21
+ 同一操作后续以蓝色(已授权常态)放行。
22
+ - 一句话语义:绿=不用管,蓝=管过了,黄=现在管,红=管也不让过(或必须人工硬确认)。
23
+ - 证据:`docs/acceptance/1.8.0/GPW-COLORS.json`。
24
+ ## [1.7.9] — 2026-10-26
25
+
26
+ **GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
27
+
28
+ - **名单策略调整:文件夹包含子文件夹**:名单条目按「根目录 + 全部子文件夹」递归匹配(`E:\work` 覆盖 `E:\work\a\b\c.txt`,`E:\workshop` 不会被误匹配;嵌套黑名单仍优先);命令工具里的相对路径按会话工作区解析后再分类。新增测试覆盖递归覆盖、同前缀兄弟目录与相对路径。
29
+ - **发布门禁稳定性**:`test/mcp-http-live.test.mjs` 的 relay 实连用例在出口网络不可达(或 MCP 工具层报 "could not reach the relay")时明确 `skip`,网络抖动不再把 release 门禁判红;其余用例仍覆盖本地绑定行为。
30
+ - **拦截即审批(不再静默挂起)**:`tools/pre-execute` 挂起 C 类调用后,客户端在 **composer 行**
31
+ (`conversation.input.left`)新增内联提示 `GpwPrompt`:显示工具名与「允许本次 / 拒绝本次 / 打开面板」;
32
+ 多个待审批显示数量。浮窗按钮增加待审批数量徽标,`w2m-gpw-open` 事件让内联提示直接打开面板。
33
+ - **名单折叠**:白名单、黑名单、最近被拦截默认各显示 3 条,其余通过「另有 N 个,展开 / 收起」折叠;
34
+ 待审批项新增「加入白名单并允许」「加入黑名单并拒绝」一键动作(服务端 pending 项补充 `folder`)。
35
+ - **名单文件防篡改(fail-closed)**:`$DSH_HOME/xclient/gpw-folders.json` 改为 **MAC 签名 v2(HMAC-SHA256)
36
+ + 原子写(tmp→rename)+ 写后只读 0444**。密钥在旁路 `*.key`(0600;或配置 `foldersKey` /
37
+ `W2M_GPW_FOLDERS_KEY`)。
38
+ - 外部修改(MAC 不匹配)、删除签名文件、去掉签名 → 状态 `tampered`,门控 **fail-closed**:所有命名路径的
39
+ 调用被直接拒绝;面板显示警告并提供「重置名单(清空并重新签名)」。
40
+ - 旧版无签名文件在首次启动时一次性迁移为 v2;升级后再次出现的无签名/缺失文件视为篡改。
41
+ - 诚实边界:DSH 与插件同用户运行时,OS 只读位可被有意的进程绕过;但签名校验使**静默改名单不可能**,
42
+ 篡改会被检测并锁定(需操作员重置)。强隔离需独立用户/服务账号(后续项)。
43
+ - **名单继承保证**:黑白名单与其 MAC 密钥位于 `$DSH_HOME/xclient/`(插件包之外),升级、重启、卸载重装都会原样继承;只有面板中的显式「移除 / 重置名单」会改变它。新增 `test/gpw-persistence.test.mjs`(5 项)覆盖。
44
+ - 测试:新增 test/gpw-hardening.test.mjs(8 项:签名往返、只读拒绝直写、MAC 不匹配/删除检测、legacy 标记、reset、密钥 sidecar)
45
+ 与 test/gpw-tamper-gate.test.mjs(4 项:篡改 fail-closed → 409 → 重置恢复 → 内联审批放行;legacy 迁移;客户端内联提示/折叠/重置静态断言)。
46
+ reset、密钥 sidecar)。`client-bundle` 更新为五个 composer 控件;`gpw-activation` 用临时名单文件隔离,
47
+ 不再写真实 `~/.dsh`。
48
+ - 证据:`gpw-hardening 8/8`、`gpw-folder-policy 9/9`、`gpw-activation 4/4`、`client-bundle 26/26`(node 进程内运行)。
49
+
10
50
  ## [1.7.8] — 2026-10-26
11
51
 
12
52
  **GPW 启用失败修复(第二个):`TypeError: Cannot read properties of undefined (reading 'layers')`。**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.8", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.8.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": "1.7.9",
3
+ "case": "GPW-HARDENING",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-25T23:26:10.976Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-hardening-20261025232610",
11
+ "requirement": "a pending class-C call is visible and answerable inline, the three folder lists collapse after three rows, and the folder lists file is MAC-signed and read-only so an external edit is detected and fails closed until the operator resets",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "inline_prompt": "GpwPrompt is mounted on conversation.input.left (id w2m-gpw-prompt): pending count + tool name + allow/deny/open-panel; the launcher shows a badge too",
16
+ "collapsed_lists": "whitelist / blacklist / recently intercepted each show 3 rows, then \"另有 N 个,展开/收起\" with per-list state",
17
+ "signing": "v2 HMAC-SHA256 covers both lists; key from foldersKey config / W2M_GPW_FOLDERS_KEY / sidecar *.key (0600); atomic tmp->rename write then 0444 read-only",
18
+ "tamper_fail_closed": "missing / unreadable / bad-MAC / unsigned-after-seen -> tampered: every path-bearing call is denied (409 in clients) until POST /api/w2m/gpw/reset re-signs",
19
+ "legacy_migration": "an unsigned pre-1.7.9 file is migrated once, then treated as tampered if it reappears",
20
+ "tests": "gpw-hardening 8/8; gpw-tamper-gate 4/4; gpw-persistence 5/5; gpw-activation 4/4; gpw-folder-policy 12/12 (recursive + relative cases included); client-bundle 26/26; claims/installed-version/acceptance-doc pass",
21
+ "pack": "scripts/pack.mjs builds the tarball deterministically; the canonical sha256 is published on the GitHub Release asset and verified equal to the npm tarball after release",
22
+ "list_inheritance": "lists and MAC key live under $DSH_HOME/xclient (outside the plugin package): upgrades, restarts and uninstall/reinstall reuse the same signed file; only explicit panel actions (remove row / reset) change the lists — covered by test/gpw-persistence.test.mjs",
23
+ "recursive_folder_lists": "a listed folder is a root covering every subfolder (E:\\work matches E:\\work\\a\\b\\c.txt; E:\\workshop does not match); a nested blacklist root still wins; relative command paths are resolved against the session workspace before classification — covered by gpw-folder-policy tests"
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "version": "1.8.0",
3
+ "case": "GPW-COLORS",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-26T05:22:38.765Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-colors-20261026052238",
11
+ "requirement": "every DSH audit behaviour is re-homed into exactly four colours; green/blue run without interruption, yellow asks the operator, red is denied outright, and an operation approved for the session becomes blue on its next occurrence",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "green": "read-only tools/commands, allow-listed commands, workspace (L2) writes -> allow",
16
+ "blue": "fingerprint approved earlier in this session -> allow as an established grant",
17
+ "yellow": "out-of-workspace writes, non-whitelisted exec, medium-risk commands, unknown tools -> GPW pending queue with colour",
18
+ "red": "dangerous-command blacklist, sensitive paths, system paths, explicit deny folders -> denied by guard and pre-execute",
19
+ "marking": "state route exposes per-colour counters and per-item colours; the panel shows the legend, counts and coloured rows",
20
+ "session_grant": "pending decision allow / allow-session records the fingerprint; the next identical call is blue",
21
+ "p2p_flake": "the direct-path p2p push case waits 60 s and skips by name (ACCEPTED_SKIPS: p2p-direct-path-timeout) instead of reddening the release gate when the runner has no usable UDP path"
22
+ }
23
+ }
24
+ }
package/lib/client.js CHANGED
@@ -155,6 +155,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
155
155
  gpwPendingEmpty: '当前没有待审批调用',
156
156
  gpwAllowOnce: '允许本次',
157
157
  gpwDenyOnce: '拒绝本次',
158
+ gpwMore: '另有 {count} 个,展开',
159
+ gpwFold: '收起',
160
+ gpwAddWhiteAllow: '加入白名单并允许',
161
+ gpwAddBlackDeny: '加入黑名单并拒绝',
162
+ gpwPendingShort: 'GPW 待审批:{tool}',
163
+ gpwPendingCount: '有 {count} 个调用等待审批',
164
+ gpwOpenPanel: '打开面板',
165
+ gpwTampered: '名单完整性校验失败({reason}),名单外路径已锁定。',
166
+ gpwReset: '重置名单(清空并重新签名)',
167
+ gpwResetConfirm: '确认重置?将清空白名单和黑名单的全部条目。',
158
168
  },
159
169
  en: {
160
170
  all: 'All machines',
@@ -207,6 +217,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
207
217
  gpwPendingEmpty: 'No calls are waiting for approval',
208
218
  gpwAllowOnce: 'Allow this call',
209
219
  gpwDenyOnce: 'Deny this call',
220
+ gpwMore: 'Show {count} more',
221
+ gpwFold: 'Show less',
222
+ gpwAddWhiteAllow: 'Always allow (add to whitelist)',
223
+ gpwAddBlackDeny: 'Always deny (add to blacklist)',
224
+ gpwPendingShort: 'GPW pending: {tool}',
225
+ gpwPendingCount: '{count} call(s) waiting for approval',
226
+ gpwOpenPanel: 'Open panel',
227
+ gpwTampered: 'Folder lists failed integrity check ({reason}); non-listed paths are locked.',
228
+ gpwReset: 'Reset lists (clear + re-sign)',
229
+ gpwResetConfirm: 'Reset the lists? Every whitelist and blacklist entry will be cleared.',
210
230
  },
211
231
  };
212
232
 
@@ -407,6 +427,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
407
427
  '.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
408
428
  '.w2mGpw_launcher{position:fixed;right:16px;bottom:96px;z-index:55;padding:6px 10px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer}',
409
429
  '.w2mGpw_launcher:hover{background:var(--dsw-alias-bg-layer-2)}',
430
+ '.w2mGpw_prompt{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
431
+ '.w2mGpw_promptLabel{font-size:12px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-primary))}',
432
+ '.w2mGpw_badge{display:inline-block;min-width:16px;padding:0 5px;margin-left:4px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:14px;text-align:center}',
410
433
  '.w2mGpw_overlay{position:fixed;inset:0;z-index:70;background:color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);display:flex;align-items:flex-end;justify-content:flex-end;padding:16px}',
411
434
  '.w2mGpw_card{width:420px;max-width:calc(100vw - 32px);max-height:min(80vh,640px);overflow:auto;box-sizing:border-box;padding:10px 12px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
412
435
  '.w2mGpw_head{display:flex;align-items:center;justify-content:space-between}',
@@ -416,6 +439,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
416
439
  '.w2mGpw_row{display:flex;align-items:center;gap:6px;min-width:0}',
417
440
  '.w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}',
418
441
  '.w2mGpw_input{box-sizing:border-box;width:100%;padding:5px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;outline:none}',
442
+ '.w2mGpw_dot{flex:none;display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:4px}',
443
+ '.w2mGpw_dot[data-c="green"]{background:var(--dsw-alias-state-success-primary)}',
444
+ '.w2mGpw_dot[data-c="blue"]{background:var(--dsw-alias-state-business-primary)}',
445
+ '.w2mGpw_dot[data-c="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
446
+ '.w2mGpw_dot[data-c="red"]{background:var(--dsw-alias-state-danger-primary)}',
447
+ '.w2mGpw_legend{display:flex;gap:10px;align-items:center;font-size:12px}',
448
+ '.w2mGpw_legendItem{display:inline-flex;align-items:center;gap:2px}',
419
449
  '.w2mGpw_actions{display:flex;flex-wrap:wrap;gap:6px}',
420
450
  '.w2mGpw_btn{padding:3px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;cursor:pointer}',
421
451
  '.w2mGpw_btn:disabled{opacity:.5;cursor:default}',
@@ -545,6 +575,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
545
575
  const [busy, setBusy] = React.useState(false);
546
576
  const [manual, setManual] = React.useState('');
547
577
  const [badPath, setBadPath] = React.useState(null);
578
+ const [expanded, setExpanded] = React.useState({});
548
579
  const call = React.useCallback(async (url, init) => {
549
580
  try {
550
581
  const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
@@ -588,13 +619,37 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
588
619
  setBusy(true);
589
620
  try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
590
621
  };
591
- const folderList = (title, list) => h('div', { key: list, className: 'w2mGpw_block' }, [
592
- h('div', { key: 'title', className: 'w2mGpw_title' }, title),
593
- ...(state?.folders?.[list] ?? []).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
594
- h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
595
- h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
596
- ])),
597
- ]);
622
+ /** v1.7.9: after a tamper report the signed lists are locked until the operator resets them. */
623
+ const resetLists = async () => {
624
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function' && window.confirm(t('gpwResetConfirm')) !== true) return;
625
+ setBusy(true);
626
+ try { await call('/api/w2m/gpw/reset', { method: 'POST', body: JSON.stringify({ confirm: 'reset' }) }); await load(); } finally { setBusy(false); }
627
+ };
628
+ /** v1.7.9: lists show three rows by default; the rest fold behind one toggle. */
629
+ const foldItems = (items, key) => (expanded[key] === true ? items : items.slice(0, 3));
630
+ const foldRow = (items, key) => (items.length <= 3 ? null : h('button', {
631
+ key: 'fold-' + key,
632
+ type: 'button',
633
+ className: 'w2mGpw_btn',
634
+ disabled: busy,
635
+ onClick: () => setExpanded((prev) => ({ ...prev, [key]: prev[key] !== true })),
636
+ }, expanded[key] === true ? t('gpwFold') : t('gpwMore', { count: items.length - 3 })));
637
+ const allowWithFolder = async (item, list) => {
638
+ if (typeof item?.folder !== 'string' || item.folder === '') return;
639
+ const added = await addFolder(list, item.folder);
640
+ if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
641
+ };
642
+ const folderList = (title, list) => {
643
+ const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
644
+ return h('div', { key: list, className: 'w2mGpw_block' }, [
645
+ h('div', { key: 'title', className: 'w2mGpw_title' }, title),
646
+ ...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
647
+ h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
648
+ h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
649
+ ])),
650
+ foldRow(items, list),
651
+ ]);
652
+ };
598
653
  const recent = Array.isArray(state?.recent) ? state.recent : [];
599
654
  const pending = Array.isArray(state?.pending) ? state.pending : [];
600
655
  return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
@@ -604,6 +659,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
604
659
  h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
605
660
  ]),
606
661
  error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
662
+ state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
663
+ h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
664
+ h('div', { key: 'a', className: 'w2mGpw_actions' }, [
665
+ h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
666
+ ]),
667
+ ]) : null,
668
+ h('div', { key: 'colours', className: 'w2mGpw_legend' }, ['green', 'blue', 'yellow', 'red'].map((colour) => h('span', { key: colour, className: 'w2mGpw_legendItem' }, [h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': colour }), String(state?.counters?.colors?.[colour] ?? 0)]))),
607
669
  h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
608
670
  h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
609
671
  h('input', {
@@ -628,7 +690,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
628
690
  recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
629
691
  ...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
630
692
  h('div', { key: 'r1', className: 'w2mGpw_row' }, [
631
- h('span', { key: 'd', className: 'w2mGpw_tag', 'data-d': item.decision }, item.decision),
693
+ h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
632
694
  h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
633
695
  ]),
634
696
  item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
@@ -636,15 +698,18 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
636
698
  h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
637
699
  ]),
638
700
  ])),
701
+ foldRow(recent, 'recent'),
639
702
  ]),
640
703
  pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
641
704
  h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
642
705
  ...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
643
- h('div', { key: 'p1', className: 'w2mGpw_path' }, item.tool + ' · ' + (item.reason ?? '')),
706
+ h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
644
707
  ...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
645
708
  h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
646
709
  h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
647
710
  h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
711
+ item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
712
+ item.folder === null || item.folder === undefined ? null : h('button', { key: 'ab', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'blacklist') }, t('gpwAddBlackDeny')),
648
713
  ]),
649
714
  ])),
650
715
  ]),
@@ -652,22 +717,107 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
652
717
  ]);
653
718
  }
654
719
 
655
- /** The floating launcher: the panel is fixed to the lower-right corner and does not consume composer space. */
720
+ /** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
656
721
  function GpwLauncher({ t }) {
657
722
  const [open, setOpen] = React.useState(false);
723
+ const [pendingCount, setPendingCount] = React.useState(0);
724
+ React.useEffect(() => {
725
+ let alive = true;
726
+ const load = async () => {
727
+ try {
728
+ const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
729
+ const body = response.ok ? await response.json() : null;
730
+ if (alive) setPendingCount(body?.ok === true && Array.isArray(body.pending) ? body.pending.length : 0);
731
+ } catch {
732
+ if (alive) setPendingCount(0);
733
+ }
734
+ };
735
+ load();
736
+ const timer = setInterval(load, 3000);
737
+ const onOpen = () => setOpen(true);
738
+ window.addEventListener('w2m-gpw-open', onOpen);
739
+ return () => {
740
+ alive = false;
741
+ clearInterval(timer);
742
+ window.removeEventListener('w2m-gpw-open', onOpen);
743
+ };
744
+ }, []);
658
745
  return h(React.Fragment, null, [
659
746
  h('button', {
660
747
  key: 'launcher',
661
748
  type: 'button',
662
749
  className: 'w2mGpw_launcher',
663
- title: t('gpwLauncher'),
750
+ title: pendingCount > 0 ? t('gpwPendingCount', { count: pendingCount }) : t('gpwLauncher'),
664
751
  'aria-expanded': open,
665
752
  onClick: () => setOpen((wasOpen) => !wasOpen),
666
- }, t('gpwLauncher')),
753
+ }, [
754
+ t('gpwLauncher'),
755
+ pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
756
+ ]),
667
757
  open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
668
758
  ]);
669
759
  }
670
760
 
761
+ /**
762
+ * v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
763
+ * (allow once / deny once / open the panel) instead of waiting silently for the operator.
764
+ */
765
+ function GpwPrompt({ t }) {
766
+ const [state, setState] = React.useState(null);
767
+ const [busy, setBusy] = React.useState(false);
768
+ React.useEffect(() => {
769
+ let alive = true;
770
+ const load = async () => {
771
+ try {
772
+ const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
773
+ const body = response.ok ? await response.json() : null;
774
+ if (alive) setState(body);
775
+ } catch {
776
+ if (alive) setState(null);
777
+ }
778
+ };
779
+ load();
780
+ const timer = setInterval(load, 2000);
781
+ return () => {
782
+ alive = false;
783
+ clearInterval(timer);
784
+ };
785
+ }, []);
786
+ const pending = Array.isArray(state?.pending) ? state.pending : [];
787
+ if (pending.length === 0) return null;
788
+ const item = pending[0];
789
+ const decide = async (decision) => {
790
+ setBusy(true);
791
+ try {
792
+ await fetch('/api/w2m/gpw/decide', {
793
+ method: 'POST',
794
+ credentials: 'same-origin',
795
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
796
+ body: JSON.stringify({ id: item.id, decision }),
797
+ });
798
+ } catch {
799
+ /* the next poll shows the queue again */
800
+ } finally {
801
+ setBusy(false);
802
+ }
803
+ };
804
+ const openPanel = () => {
805
+ try {
806
+ window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
807
+ } catch {
808
+ /* no window in a headless render */
809
+ }
810
+ };
811
+ return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
812
+ h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
813
+ ? t('gpwPendingCount', { count: pending.length })
814
+ : t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
815
+ h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
816
+ h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
817
+ h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
818
+ ]);
819
+ }
820
+
671
821
  /** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
672
822
  function PluginUpdate({ t }) {
673
823
  const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
@@ -1543,6 +1693,19 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1543
1693
  } catch (error) {
1544
1694
  console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
1545
1695
  }
1696
+ // v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
1697
+ // asks allow/deny immediately instead of waiting silently in the panel.
1698
+ try {
1699
+ slots.inject('conversation.input.left', () => slots.register({
1700
+ name: 'conversation.input.left',
1701
+ id: 'w2m-gpw-prompt',
1702
+ order: 44,
1703
+ label: 'GPW pending prompt',
1704
+ inject: () => ({ t }),
1705
+ }, GpwPrompt));
1706
+ } catch (error) {
1707
+ console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
1708
+ }
1546
1709
  try {
1547
1710
  slots.inject('conversation.input.left', () => slots.register({
1548
1711
  name: 'conversation.input.left',
@@ -1601,6 +1764,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1601
1764
  exports.inject = inject;
1602
1765
  exports.TargetSelect = TargetSelect;
1603
1766
  exports.GpwLauncher = GpwLauncher;
1767
+ exports.GpwPrompt = GpwPrompt;
1604
1768
  exports.FleetChat = FleetChat;
1605
1769
  exports.injectStyle = injectStyle;
1606
1770
  exports.makeTranslator = makeTranslator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.7.8",
3
+ "version": "1.8.0",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -82,7 +82,7 @@
82
82
  "test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
83
83
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
84
84
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
85
- "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs",
85
+ "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs",
86
86
  "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
87
87
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
88
88
  "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.7.8",
4
+ "version": "1.8.0",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -107,7 +107,7 @@ const COMMAND_TOOL = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i
107
107
  * @param {{name?: string, arguments?: string|object}} call
108
108
  * @returns {string[]} raw (not yet normalised) path candidates
109
109
  */
110
- export function pathsOfToolCall(call) {
110
+ export function pathsOfToolCall(call, options = {}) {
111
111
  let args = call?.arguments;
112
112
  if (typeof args === 'string') {
113
113
  if (args.trim() === '') return [];
@@ -137,8 +137,20 @@ export function pathsOfToolCall(call) {
137
137
  : typeof call?.arguments === 'string'
138
138
  ? call.arguments
139
139
  : JSON.stringify(args ?? '');
140
- const matches = text.match(/(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
140
+ const matches = text.match(/(?<![^\s"'=(])(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
141
141
  for (const m of matches) push(m);
142
+ // v1.7.9: relative tokens (sub/file.txt, ./x, ..\y) are resolved against the workspace base below, so a command
143
+ // that names a subfolder is classified instead of passing through as "names no path". Only tokens that actually
144
+ // contain a separator are taken; URLs, flags and bare words are left alone.
145
+ if (options.allowRelative === true) {
146
+ for (const token of text.split(/\s+/)) {
147
+ const cleaned = token.replace(/^["'(=]+|["'),;]+$/g, '');
148
+ if (cleaned === '' || cleaned.startsWith('-') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(cleaned)) continue;
149
+ if (!/[\\/]/.test(cleaned)) continue;
150
+ if (/[\\/]$/.test(cleaned) && cleaned.length === 1) continue;
151
+ push(cleaned);
152
+ }
153
+ }
142
154
  }
143
155
  return out;
144
156
  }
@@ -149,11 +161,11 @@ export function pathsOfToolCall(call) {
149
161
  * the ordinary one.
150
162
  *
151
163
  * @param {{name?: string, arguments?: string|object, whitelist?: string[], blacklist?: string[],
152
- * workspace?: string|null, base?: string, platform?: string}} call
164
+ * workspace?: string|null, base?: string, platform?: string, allowRelative?: boolean}} call
153
165
  * @returns {{decision: 'deny'|'allow'|'pass', path: string|null, folder: string|null, reason: string, paths: string[]}}
154
166
  */
155
167
  export function classifyToolCall(call) {
156
- const candidates = pathsOfToolCall(call);
168
+ const candidates = pathsOfToolCall(call, { allowRelative: call.allowRelative === true });
157
169
  let weakest = { decision: 'pass', path: null, folder: null, reason: 'this call names no path', paths: [] };
158
170
  for (const candidate of candidates) {
159
171
  const result = classifyPath({
@@ -0,0 +1,144 @@
1
+ /**
2
+ * GPW four-colour audit model (v1.7.10).
3
+ *
4
+ * DSH already has a rich set of audit behaviours across many plugins. This module re-homes every one of them into
5
+ * exactly four colours, so the operator sees one vocabulary instead of a dozen plugin names:
6
+ *
7
+ * green (通过) safe, reversible, read-only, or a whitelist hit -> run, no prompt, no interruption
8
+ * blue (正常) the audit already happened: approved this session, learned, or covered by an established grant
9
+ * yellow (警报) a human has to nod: privileged writes, non-whitelisted exec, sandbox escalation, medium risk
10
+ * red (风险) hard boundary: denied outright, never auto-approved, fail-closed when nobody answers
11
+ *
12
+ * The one-sentence distinction: green is "don't care", blue is "cared before", yellow is "care now", red is
13
+ * "care won't help — it does not pass, or only a human override can".
14
+ *
15
+ * This file is pure: no session, no filesystem, no plugin context. The gate itself lives in gpw.mjs, which calls
16
+ * classifyGpwCall() and turns the colour into allow / pending / deny.
17
+ */
18
+
19
+ export const GPW_COLORS = Object.freeze(['green', 'blue', 'yellow', 'red']);
20
+
21
+ /** Human labels, used by the panel and by log lines. */
22
+ export const GPW_COLOR_LABELS = Object.freeze({
23
+ green: '通过(自动放行)',
24
+ blue: '正常(已授权/已确认)',
25
+ yellow: '警报(需要人工确认)',
26
+ red: '风险(直接拒绝/强制拦截)',
27
+ });
28
+
29
+ /** Tools that only read: green by their nature. */
30
+ const READONLY_TOOLS = /^(read|search|grep|glob|list|dir|ls|web_search|web_fetch|fetch|cat|head|tail|status|health|metrics|inspect)$/i;
31
+
32
+ /** Command tools: their argv decides, not the tool name. */
33
+ const COMMAND_TOOLS = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i;
34
+
35
+ /** Write/edit tools. */
36
+ const WRITE_TOOLS = /^(write|edit|create|apply_patch|delete|remove|mv|move|rename|mkdir|touch|copy|cp)$/i;
37
+
38
+ /** Known read-only command prefixes (L1/L2 safe). */
39
+ const READONLY_COMMAND = /^(ls|cat|head|tail|grep|find|stat|wc|pwd|echo|which|where|node -v|node --version|npm --version|git status|git log|git diff|git show|git branch)\b/;
40
+
41
+ /** Commands that hit the whitelist rule in the operator's definition (explicit allow-list entries). */
42
+ const ALLOWED_COMMAND = /^(python\s+_scripts\/lint\.py|node\s+scripts\/lint\.mjs|npm\s+run\s+lint|npx\s+prettier)\b/;
43
+
44
+ /** Hard-red command forms: no approval path at all (matching DSH's dangerous-command blacklist). */
45
+ const RED_COMMANDS = [
46
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)*\/(\s|$)/, // rm -rf /
47
+ /\bmkfs(\.[a-z0-9]+)?\b/i,
48
+ /\bdd\s+if=/i,
49
+ /\b(shutdown|reboot|halt|poweroff)\b/i,
50
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, // fork bomb
51
+ /\b(curl|wget)[^|;]*\|\s*(ba|z|da)?sh\b/i,
52
+ /\bchmod\s+(-R\s+)?(777|000)\s+\//i,
53
+ ];
54
+
55
+ /** Sensitive path patterns: reading them is red (DSH treats .env/.ssh/id_rsa/*.pem//etc/passwd as forbidden). */
56
+ const SENSITIVE_PATH = /(?:^|[\\/])(\.env(\..*)?|\.ssh|id_rsa|id_ed25519|.*\.pem|.*\.key|credentials|passwd|shadow|\.gnupg)(?:[\\/]|$)/i;
57
+
58
+ /** System paths: writes are red, reads are red too (DSH hard-denies system-path destruction/credential access). */
59
+ const SYSTEM_PATH = /^(?:[A-Za-z]:[\\/](?:Windows|Program Files|Program Files \(x86\)|ProgramData)(?:[\\/]|$)|\/(?:etc|usr|bin|sbin|boot|dev|proc|sys)(?:\/|$))/i;
60
+
61
+ /** Medium risk commands: approved by a human, then learned. */
62
+ const YELLOW_COMMANDS = [
63
+ /\bgit\s+reset\s+--hard\b/i,
64
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)/i, // recursive delete of a project directory
65
+ /\brmdir\s+/i,
66
+ /\bnpm\s+(i|install|publish)\b/i,
67
+ /\bpip\s+install\b/i,
68
+ /\b(curl|wget)\b/i, // network egress
69
+ ];
70
+
71
+ /** Fingerprint of an operation, for session grants and learned approvals. */
72
+ export function fingerprintOf(call) {
73
+ const name = String(call?.name ?? 'tool').toLowerCase();
74
+ const args = call?.arguments ?? null;
75
+ const text = typeof args === 'string' ? args : JSON.stringify(args ?? {});
76
+ // Normalise the volatile parts so the same operation twice is the same fingerprint.
77
+ return name + '|' + text.replace(/\s+/g, ' ').slice(0, 400);
78
+ }
79
+
80
+ /**
81
+ * Classify one tool call into a colour.
82
+ *
83
+ * @param {{name?: string, arguments?: string|object, paths?: string[], whitelist?: string[], blacklist?: string[],
84
+ * workspace?: string|null, authorized?: boolean|Set<string>, fingerprint?: string}} input
85
+ * @returns {{color: 'green'|'blue'|'yellow'|'red', reason: string, label: string}}
86
+ */
87
+ export function classifyGpwCall(input) {
88
+ const name = String(input?.name ?? 'tool');
89
+ const args = input?.arguments;
90
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
91
+ const workspace = typeof input?.workspace === 'string' && input.workspace !== '' ? input.workspace : null;
92
+ const authorized = input?.authorized === true;
93
+
94
+ const commandText = (() => {
95
+ if (typeof args === 'string') return args;
96
+ if (args !== null && typeof args === 'object') {
97
+ if (typeof args.command === 'string') return args.command;
98
+ if (typeof args.cmd === 'string') return args.cmd;
99
+ }
100
+ return '';
101
+ })();
102
+
103
+ const say = (color, reason) => ({ color, reason, label: GPW_COLOR_LABELS[color] });
104
+
105
+ // ---- red: hard boundaries, no approval path ---------------------------------------------------------------
106
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
107
+ for (const pattern of RED_COMMANDS) {
108
+ if (pattern.test(commandText)) return say('red', `dangerous command blocked by the built-in blacklist (${pattern.source.slice(0, 40)})`);
109
+ }
110
+ }
111
+ for (const path of paths) {
112
+ if (SENSITIVE_PATH.test(path)) return say('red', `sensitive path "${path}" is never readable`);
113
+ if (SYSTEM_PATH.test(path)) return say('red', `system path "${path}" is never writable`);
114
+ }
115
+ for (const path of paths) {
116
+ if (Array.isArray(input?.blacklist) && input.blacklist.some((folder) => path.replace(/\\/g, '/').toLowerCase().startsWith(folder.replace(/\\/g, '/').toLowerCase()))) {
117
+ return say('red', `folder "${path}" is on the explicit deny list`);
118
+ }
119
+ }
120
+
121
+ // ---- blue: the audit already happened ----------------------------------------------------------------------
122
+ if (authorized) return say('blue', 'approved earlier in this session, so it runs as an established grant');
123
+
124
+ // ---- green: no human needed ---------------------------------------------------------------------------------
125
+ if (READONLY_TOOLS.test(name)) return say('green', 'read-only operation');
126
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
127
+ if (READONLY_COMMAND.test(commandText.trim())) return say('green', 'read-only command');
128
+ if (ALLOWED_COMMAND.test(commandText.trim())) return say('green', 'command hits the configured allow-list');
129
+ }
130
+ if (WRITE_TOOLS.test(name) && paths.length > 0 && workspace !== null) {
131
+ const inWorkspace = paths.every((path) => path.replace(/\\/g, '/').toLowerCase().startsWith(workspace.replace(/\\/g, '/').toLowerCase()));
132
+ if (inWorkspace) return say('green', 'write stays inside the workspace (L2)');
133
+ }
134
+
135
+ // ---- yellow: a human has to nod -----------------------------------------------------------------------------
136
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
137
+ for (const pattern of YELLOW_COMMANDS) {
138
+ if (pattern.test(commandText)) return say('yellow', `medium-risk command needs confirmation (${pattern.source.slice(0, 30)})`);
139
+ }
140
+ return say('yellow', 'command is not in the read-only or allow-listed set');
141
+ }
142
+ if (WRITE_TOOLS.test(name)) return say('yellow', 'write needs confirmation (outside the workspace or unnamed path)');
143
+ return say('yellow', 'tool is not known to be safe, so a human decides (unknown tool -> fail-closed to the operator)');
144
+ }
@@ -25,19 +25,22 @@
25
25
  *
26
26
  * ## Operator surface
27
27
  *
28
- * The panel is rendered by the w2m client (`lib/client.js`): a floating "GPW 文件夹" button opens the three sections
29
- * (whitelist / blacklist / recently intercepted) plus the pending queue. Folders can be typed (validated against the
30
- * filesystem here) or taken from the recently-intercepted list in one click. Lists live in a JSON file under
31
- * `$DSH_HOME/xclient/`, never in the profile patch.
28
+ * The panel is rendered by the w2m client (`lib/client.js`): a pending prompt sits inline in the composer row,
29
+ * and a floating "GPW 文件夹" button opens the three sections (whitelist / blacklist / recently intercepted)
30
+ * plus the pending queue. Folders can be typed (validated against the filesystem here) or taken from the
31
+ * recently-intercepted list in one click. Lists live in a MAC-signed v2 JSON file under `$DSH_HOME/xclient/`
32
+ * (written read-only 0444), never in the profile patch; an external edit is detected and locks class-B/C paths
33
+ * until the operator resets the lists.
32
34
  */
33
35
 
34
36
  import { spawn } from 'node:child_process';
35
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
37
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
36
38
  import { homedir } from 'node:os';
37
39
  import { dirname, join, resolve } from 'node:path';
38
- import { randomUUID } from 'node:crypto';
40
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto';
39
41
 
40
42
  import { classifyToolCall } from './folder-policy.mjs';
43
+ import { classifyGpwCall, fingerprintOf } from './gpw-colors.mjs';
41
44
 
42
45
  export const name = 'dsh-gpw';
43
46
 
@@ -49,11 +52,15 @@ export const GPW_ROUTE = '/api/w2m/gpw/state';
49
52
  export const GPW_FOLDERS_ROUTE = '/api/w2m/gpw/folders';
50
53
  export const GPW_DECIDE_ROUTE = '/api/w2m/gpw/decide';
51
54
  export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
55
+ export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
52
56
 
53
57
  const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
54
58
  const DEFAULT_PENDING_TIMEOUT_MS = 120_000;
55
59
  const RECENT_LIMIT = 20;
56
60
  const NO_STORE = { 'cache-control': 'no-store' };
61
+ /** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
62
+ const FOLDERS_VERSION = 2;
63
+ const FOLDERS_MAC_ALGO = 'sha256';
57
64
 
58
65
  /** Resolve the folders file: config first, then `$DSH_HOME/xclient/gpw-folders.json`. */
59
66
  export function foldersFileOf(config = {}) {
@@ -62,28 +69,116 @@ export function foldersFileOf(config = {}) {
62
69
  return join(home, 'xclient', 'gpw-folders.json');
63
70
  }
64
71
 
65
- /** Read the operator-managed lists. A missing or corrupt file is empty lists, never a crash on mount. */
66
- export function readFolders(file) {
72
+ /** The sidecar file that holds the MAC key. Kept next to (never inside) the lists file. */
73
+ export function foldersKeyFileOf(file) {
74
+ return `${file}.key`;
75
+ }
76
+
77
+ /**
78
+ * Resolve the MAC key: explicit config / env first, else a generated 32-byte key in a 0600 sidecar.
79
+ * `created` is true only when this call generated the key, which is what lets a legacy plain-JSON
80
+ * file be migrated exactly once and a later unsigned file be treated as tampering instead.
81
+ */
82
+ export function ensureFoldersKey(file, config = {}) {
83
+ const fromConfig = typeof config.foldersKey === 'string' && config.foldersKey.trim() !== '' ? config.foldersKey.trim() : null;
84
+ const fromEnv = typeof process.env.W2M_GPW_FOLDERS_KEY === 'string' && process.env.W2M_GPW_FOLDERS_KEY.trim() !== ''
85
+ ? process.env.W2M_GPW_FOLDERS_KEY.trim()
86
+ : null;
87
+ const explicit = fromConfig ?? fromEnv;
88
+ if (explicit !== null) return { key: explicit, created: false, source: fromConfig !== null ? 'config' : 'env' };
89
+ const keyFile = foldersKeyFileOf(file);
67
90
  try {
68
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
69
- return {
70
- whitelist: Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [],
71
- blacklist: Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [],
72
- };
91
+ const existing = readFileSync(keyFile, 'utf8').trim();
92
+ if (existing !== '') return { key: existing, created: false, source: 'file' };
73
93
  } catch {
74
- return { whitelist: [], blacklist: [] };
94
+ /* generated below */
75
95
  }
96
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
97
+ const key = randomBytes(32).toString('hex');
98
+ writeFileSync(keyFile, `${key}\n`, { encoding: 'utf8', mode: 0o600 });
99
+ try { chmodSync(keyFile, 0o600); } catch { /* best effort on Windows */ }
100
+ return { key, created: true, source: 'file' };
76
101
  }
77
102
 
78
- /** Persist the operator-managed lists. */
79
- export function writeFolders(file, folders) {
80
- mkdirSync(dirname(file), { recursive: true });
81
- const next = {
82
- whitelist: [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
83
- blacklist: [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
84
- };
85
- writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
86
- return next;
103
+ /** Canonical MAC over the two lists (order-independent, so adding/removing a row is what changes it). */
104
+ export function macOfFolders(whitelist, blacklist, key) {
105
+ const canonical = JSON.stringify({
106
+ whitelist: [...new Set(whitelist)].sort(),
107
+ blacklist: [...new Set(blacklist)].sort(),
108
+ });
109
+ return createHmac(FOLDERS_MAC_ALGO, key).update(canonical).digest('hex');
110
+ }
111
+
112
+ /**
113
+ * Read the operator-managed lists.
114
+ *
115
+ * - missing file + `allowMissing` -> clean empty lists (first run)
116
+ * - missing file otherwise -> `tampered` (the signed file was deleted)
117
+ * - valid v2 + MAC -> lists
118
+ * - v2 with a bad MAC -> `tampered` (edited outside GPW)
119
+ * - no version/MAC (legacy) -> `legacy: true`; the caller migrates once or treats it as tampered
120
+ */
121
+ export function readFolders(file, key = null, options = {}) {
122
+ let raw;
123
+ try {
124
+ raw = readFileSync(file, 'utf8');
125
+ } catch (error) {
126
+ if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
127
+ return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
128
+ }
129
+ if (error?.code !== 'ENOENT') {
130
+ return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
131
+ }
132
+ return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
133
+ }
134
+ let parsed = null;
135
+ try {
136
+ parsed = JSON.parse(raw);
137
+ } catch {
138
+ return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
139
+ }
140
+ const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
141
+ const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
142
+ if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
143
+ return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
144
+ }
145
+ if (key === null) return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
146
+ if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
147
+ return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
148
+ }
149
+ return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
150
+ }
151
+
152
+ /**
153
+ * Persist the operator-managed lists.
154
+ *
155
+ * Atomic (tmp + rename), MAC-signed when a key is supplied, and left **read-only** (0444) so an
156
+ * accidental or casual edit from the agent's file tools fails instead of silently changing policy.
157
+ * GPW itself rewrites by clearing the flag, writing a new tmp file and renaming over the target.
158
+ */
159
+ export function writeFolders(file, folders, key = null) {
160
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
161
+ const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
162
+ const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
163
+ const next = { version: FOLDERS_VERSION, whitelist, blacklist };
164
+ if (key !== null) {
165
+ next.algo = FOLDERS_MAC_ALGO;
166
+ next.mac = macOfFolders(whitelist, blacklist, key);
167
+ }
168
+ const payload = `${JSON.stringify(next, null, 2)}\n`;
169
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
170
+ // Windows refuses to rename over a read-only target: clear it first, then the atomic replace runs.
171
+ try { chmodSync(file, 0o644); } catch { /* first write */ }
172
+ writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
173
+ try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
174
+ renameSync(tmp, file);
175
+ try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
176
+ return { whitelist, blacklist };
177
+ }
178
+
179
+ /** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
180
+ export function resetFolders(file, key) {
181
+ return writeFolders(file, { whitelist: [], blacklist: [] }, key);
87
182
  }
88
183
 
89
184
  /**
@@ -229,6 +324,33 @@ export function apply(ctx, config = {}) {
229
324
  const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
230
325
  const requireFullAccess = config.requireFullAccess !== false;
231
326
  const foldersFile = foldersFileOf(config);
327
+ const keyInfo = ensureFoldersKey(foldersFile, config);
328
+ const foldersKey = keyInfo.key;
329
+ /** Set once the signed file has been read cleanly; a later disappearance then reads as tampering. */
330
+ let foldersSeen = false;
331
+ /** Non-null until the operator resets the lists; class-B/C paths are locked while it is set. */
332
+ let tamperedReason = null;
333
+
334
+ const noteTamper = (reason) => {
335
+ const next = reason ?? 'folder lists failed integrity check';
336
+ if (tamperedReason === null) ctx.logger?.warn?.(`dsh-gpw: ${next}; class-B/C paths are locked until the operator resets the lists`);
337
+ tamperedReason = next;
338
+ };
339
+
340
+ // First read: migrate a legacy plain-JSON file exactly once; after that an unsigned/missing file is tampering.
341
+ const initialFolders = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created || !existsSync(foldersFile) });
342
+ if (initialFolders.legacy) ctx.logger?.info?.('dsh-gpw: migrating legacy folder lists to MAC-signed v2 (read-only)');
343
+ if (initialFolders.tampered) {
344
+ noteTamper(initialFolders.reason);
345
+ } else {
346
+ // Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
347
+ try {
348
+ writeFolders(foldersFile, initialFolders, foldersKey);
349
+ foldersSeen = true;
350
+ } catch (error) {
351
+ noteTamper(`folder lists could not be initialized: ${error?.message ?? error}`);
352
+ }
353
+ }
232
354
 
233
355
  /** Timestamps of auto-approvals in the current window. */
234
356
  const approvals = [];
@@ -240,6 +362,10 @@ export function apply(ctx, config = {}) {
240
362
  const pending = new Map();
241
363
  /** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
242
364
  const recent = [];
365
+ /** Four-colour counters: every decision lands in exactly one bucket (v1.7.10). */
366
+ const colorCounts = { green: 0, blue: 0, yellow: 0, red: 0 };
367
+ /** Operation fingerprints approved earlier in this session -> the blue ("正常") state. */
368
+ const sessionGrants = new Set();
243
369
 
244
370
  const noteRecent = (entry) => {
245
371
  recent.unshift({ at: Date.now(), ...entry });
@@ -247,11 +373,20 @@ export function apply(ctx, config = {}) {
247
373
  };
248
374
 
249
375
  const folderLists = () => {
250
- const stored = readFolders(foldersFile);
376
+ const stored = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created && !foldersSeen });
377
+ if (stored.legacy) {
378
+ noteTamper('folder lists lost their integrity signature (edited outside GPW?)');
379
+ } else if (stored.tampered) {
380
+ noteTamper(stored.reason);
381
+ } else {
382
+ foldersSeen = true;
383
+ }
251
384
  const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
252
385
  return {
253
386
  whitelist: merge(config.whitelist, stored.whitelist),
254
387
  blacklist: merge(config.blacklist, stored.blacklist),
388
+ tampered: tamperedReason !== null,
389
+ reason: tamperedReason,
255
390
  };
256
391
  };
257
392
 
@@ -289,6 +424,8 @@ export function apply(ctx, config = {}) {
289
424
  tool: String(toolCallOf(exec).name ?? 'tool'),
290
425
  reason,
291
426
  paths,
427
+ color: 'yellow',
428
+ fingerprint: fingerprintOf(toolCallOf(exec)),
292
429
  };
293
430
  pending.set(id, entry);
294
431
  pendingTotal += 1;
@@ -311,9 +448,10 @@ export function apply(ctx, config = {}) {
311
448
  } catch {
312
449
  /* already fired */
313
450
  }
314
- if (decision === 'allow') {
451
+ if (decision === 'allow' || decision === 'allow-session') {
315
452
  allowedTotal += 1;
316
- entry.resolve({ kind: 'allow', reason: 'GPW: allowed by the operator' });
453
+ if (typeof entry.fingerprint === 'string' && entry.fingerprint !== '') sessionGrants.add(entry.fingerprint);
454
+ entry.resolve({ kind: 'allow', reason: decision === 'allow-session' ? 'GPW: allowed for this session (blue)' : 'GPW: allowed by the operator' });
317
455
  } else {
318
456
  deniedTotal += 1;
319
457
  entry.resolve({ kind: 'deny', reason: 'GPW: denied by the operator' });
@@ -335,8 +473,17 @@ export function apply(ctx, config = {}) {
335
473
  const blacklistGuard = () => (exec) => {
336
474
  if (!isFullAccess(exec?.agent)) return undefined;
337
475
  const lists = folderLists();
338
- if (lists.blacklist.length === 0) return undefined;
339
476
  const call = toolCallOf(exec);
477
+ if (lists.tampered) {
478
+ // Fail closed: while the lists cannot be trusted, every path-naming call is refused outright.
479
+ const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
480
+ if (probe.paths.length === 0) return undefined;
481
+ deniedTotal += 1;
482
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
483
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
484
+ return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
485
+ }
486
+ if (lists.blacklist.length === 0) return undefined;
340
487
  const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
341
488
  if (result.decision !== 'deny') return undefined;
342
489
  deniedTotal += 1;
@@ -372,30 +519,71 @@ export function apply(ctx, config = {}) {
372
519
  ctx.on('tools/pre-execute', async (exec, next) => {
373
520
  if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
374
521
  const lists = folderLists();
375
- if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
376
522
  const call = toolCallOf(exec);
523
+ if (lists.tampered) {
524
+ // Fail closed: a tampered list file must not be able to silently turn policy off.
525
+ const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
526
+ if (probe.paths.length === 0) return next();
527
+ deniedTotal += 1;
528
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
529
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
530
+ return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
531
+ }
377
532
  let cwd = null;
378
533
  try {
379
534
  cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
380
535
  } catch {
381
536
  cwd = null;
382
537
  }
383
- const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null });
384
- if (classification.decision === 'pass' && classification.paths.length === 0) return next();
538
+ // A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
539
+ // subfolder reference is classified rather than treated as "names no path".
540
+ const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
541
+ // v1.7.10: one four-colour decision in front of the folder plan, so every audit behaviour in DSH has one home.
542
+ const fingerprint = fingerprintOf(call);
543
+ const colour = classifyGpwCall({
544
+ name: call.name,
545
+ arguments: call.arguments,
546
+ paths: classification.paths,
547
+ whitelist: lists.whitelist,
548
+ blacklist: lists.blacklist,
549
+ workspace: cwd ?? null,
550
+ authorized: sessionGrants.has(fingerprint),
551
+ });
552
+ if (colour.color === 'red') {
553
+ deniedTotal += 1;
554
+ colorCounts.red += 1;
555
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: colour.reason, paths: classification.paths });
556
+ ctx.logger?.warn?.(`dsh-gpw: red -- refused ${String(call.name ?? 'tool')} -- ${colour.reason}`);
557
+ return { kind: 'deny', reason: `GPW red: ${colour.reason}` };
558
+ }
559
+ if (colour.color === 'green' || colour.color === 'blue') {
560
+ colorCounts[colour.color] += 1;
561
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: colour.color, reason: colour.reason, paths: classification.paths });
562
+ ctx.logger?.info?.(`dsh-gpw: ${colour.color} -- ${String(call.name ?? 'tool')} -- ${colour.reason}`);
563
+ return { kind: 'allow' };
564
+ }
565
+ if (classification.decision === 'pass' && classification.paths.length === 0) {
566
+ colorCounts.yellow += 1;
567
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: colour.reason, paths: [] });
568
+ return waitForOperator(exec, colour.reason, []);
569
+ }
385
570
  const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
386
571
  const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
387
572
  if (plan.kind === 'deny') {
388
573
  deniedTotal += 1;
389
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: plan.reason, paths: classification.paths });
574
+ colorCounts.red += 1;
575
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: plan.reason, paths: classification.paths });
390
576
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
391
577
  return { kind: 'deny', reason: `GPW: ${plan.reason}` };
392
578
  }
393
579
  if (plan.kind === 'allow') {
394
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', reason: plan.reason, paths: classification.paths });
580
+ colorCounts.green += 1;
581
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: 'green', reason: plan.reason, paths: classification.paths });
395
582
  ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
396
583
  return { kind: 'allow' };
397
584
  }
398
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', reason: plan.reason, paths: classification.paths });
585
+ colorCounts.yellow += 1;
586
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: plan.reason, paths: classification.paths });
399
587
  ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
400
588
  return waitForOperator(exec, plan.reason, classification.paths);
401
589
  });
@@ -410,28 +598,35 @@ export function apply(ctx, config = {}) {
410
598
  path: GPW_ROUTE,
411
599
  methods: ['GET'],
412
600
  requestBody: 'buffered',
413
- fetch: async () => json({
414
- ok: true,
415
- enabled: true,
416
- require_full_access: requireFullAccess,
417
- folders: folderLists(),
418
- counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
419
- recent: recent.map((entry) => ({
420
- at: entry.at,
421
- tool: entry.tool,
422
- decision: entry.decision,
423
- reason: entry.reason,
424
- paths: entry.paths,
425
- folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
426
- })),
427
- pending: [...pending.entries()].map(([id, entry]) => ({
428
- id,
429
- tool: entry.tool,
430
- reason: entry.reason,
431
- paths: entry.paths,
432
- pending_ms: Date.now() - entry.createdAt,
433
- })),
434
- }),
601
+ fetch: async () => {
602
+ const lists = folderLists();
603
+ return json({
604
+ ok: true,
605
+ enabled: true,
606
+ require_full_access: requireFullAccess,
607
+ folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
608
+ tampered: lists.tampered,
609
+ tampered_reason: lists.reason,
610
+ counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
611
+ recent: recent.map((entry) => ({
612
+ at: entry.at,
613
+ tool: entry.tool,
614
+ decision: entry.decision,
615
+ color: entry.color ?? null,
616
+ reason: entry.reason,
617
+ paths: entry.paths,
618
+ folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
619
+ })),
620
+ pending: [...pending.entries()].map(([id, entry]) => ({
621
+ id,
622
+ tool: entry.tool,
623
+ reason: entry.reason,
624
+ paths: entry.paths,
625
+ folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
626
+ pending_ms: Date.now() - entry.createdAt,
627
+ })),
628
+ });
629
+ },
435
630
  });
436
631
 
437
632
  registry.register({
@@ -448,23 +643,58 @@ export function apply(ctx, config = {}) {
448
643
  const list = body?.list;
449
644
  if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
450
645
  if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
646
+ const current = folderLists();
647
+ if (current.tampered) {
648
+ return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
649
+ }
650
+ const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
451
651
  if (body.action === 'remove') {
452
652
  if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
453
- const stored = readFolders(foldersFile);
454
653
  const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
455
- writeFolders(foldersFile, next);
654
+ try {
655
+ writeFolders(foldersFile, next, foldersKey);
656
+ } catch (error) {
657
+ return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
658
+ }
456
659
  return json({ ok: true, folders: folderLists() });
457
660
  }
458
661
  const validated = validateFolderInput(body?.path);
459
662
  if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
460
- const stored = readFolders(foldersFile);
461
663
  const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
462
- writeFolders(foldersFile, next);
664
+ try {
665
+ writeFolders(foldersFile, next, foldersKey);
666
+ } catch (error) {
667
+ return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
668
+ }
463
669
  ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
464
670
  return json({ ok: true, folders: folderLists() });
465
671
  },
466
672
  });
467
673
 
674
+ registry.register({
675
+ path: GPW_RESET_ROUTE,
676
+ methods: ['POST'],
677
+ requestBody: 'buffered',
678
+ fetch: async (request) => {
679
+ let body = null;
680
+ try {
681
+ body = await request.json();
682
+ } catch {
683
+ /* refused below */
684
+ }
685
+ if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
686
+ try {
687
+ resetFolders(foldersFile, foldersKey);
688
+ } catch (error) {
689
+ return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
690
+ }
691
+ tamperedReason = null;
692
+ foldersSeen = true;
693
+ ctx.logger?.warn?.('dsh-gpw: folder lists were reset by the operator; all entries cleared and re-signed');
694
+ return json({ ok: true, tampered: false, folders: folderLists() });
695
+ },
696
+ });
697
+
468
698
  registry.register({
469
699
  path: GPW_DECIDE_ROUTE,
470
700
  methods: ['POST'],
@@ -476,8 +706,8 @@ export function apply(ctx, config = {}) {
476
706
  } catch {
477
707
  /* refused below */
478
708
  }
479
- if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny')) {
480
- return json({ ok: false, error: 'id and decision (allow|deny) are required' }, 400);
709
+ if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny' && body?.decision !== 'allow-session')) {
710
+ return json({ ok: false, error: 'id and decision (allow|allow-session|deny) are required' }, 400);
481
711
  }
482
712
  const result = decidePending(body.id, body.decision);
483
713
  return json(result.ok ? { ok: true } : { ok: false, error: result.error }, result.ok ? 200 : 404);
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
283
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
284
  * overrides it at runtime for tests and packagers.
285
285
  */
286
- const PLUGIN_VERSION = '1.7.8';
286
+ const PLUGIN_VERSION = '1.8.0';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;