@twinsearth/w2m-dsh-plugin 1.7.8 → 1.7.9
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.7.9/GPW-HARDENING.json +26 -0
- package/lib/client.js +166 -10
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/plugin/folder-policy.mjs +16 -4
- package/src/plugin/gpw.mjs +244 -53
- package/src/plugin/tools.mjs +1 -1
|
@@ -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
|
+
"version": "1.7.9"
|
|
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.
|
|
15
|
+
"version": "1.7.9",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.7.
|
|
29
|
+
"version": "1.7.9"
|
|
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.
|
|
4
|
+
"version": "1.7.9",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.7.9] — 2026-10-26
|
|
11
|
+
|
|
12
|
+
**GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
|
|
13
|
+
|
|
14
|
+
- **名单策略调整:文件夹包含子文件夹**:名单条目按「根目录 + 全部子文件夹」递归匹配(`E:\work` 覆盖 `E:\work\a\b\c.txt`,`E:\workshop` 不会被误匹配;嵌套黑名单仍优先);命令工具里的相对路径按会话工作区解析后再分类。新增测试覆盖递归覆盖、同前缀兄弟目录与相对路径。
|
|
15
|
+
- **发布门禁稳定性**:`test/mcp-http-live.test.mjs` 的 relay 实连用例在出口网络不可达(或 MCP 工具层报 "could not reach the relay")时明确 `skip`,网络抖动不再把 release 门禁判红;其余用例仍覆盖本地绑定行为。
|
|
16
|
+
- **拦截即审批(不再静默挂起)**:`tools/pre-execute` 挂起 C 类调用后,客户端在 **composer 行**
|
|
17
|
+
(`conversation.input.left`)新增内联提示 `GpwPrompt`:显示工具名与「允许本次 / 拒绝本次 / 打开面板」;
|
|
18
|
+
多个待审批显示数量。浮窗按钮增加待审批数量徽标,`w2m-gpw-open` 事件让内联提示直接打开面板。
|
|
19
|
+
- **名单折叠**:白名单、黑名单、最近被拦截默认各显示 3 条,其余通过「另有 N 个,展开 / 收起」折叠;
|
|
20
|
+
待审批项新增「加入白名单并允许」「加入黑名单并拒绝」一键动作(服务端 pending 项补充 `folder`)。
|
|
21
|
+
- **名单文件防篡改(fail-closed)**:`$DSH_HOME/xclient/gpw-folders.json` 改为 **MAC 签名 v2(HMAC-SHA256)
|
|
22
|
+
+ 原子写(tmp→rename)+ 写后只读 0444**。密钥在旁路 `*.key`(0600;或配置 `foldersKey` /
|
|
23
|
+
`W2M_GPW_FOLDERS_KEY`)。
|
|
24
|
+
- 外部修改(MAC 不匹配)、删除签名文件、去掉签名 → 状态 `tampered`,门控 **fail-closed**:所有命名路径的
|
|
25
|
+
调用被直接拒绝;面板显示警告并提供「重置名单(清空并重新签名)」。
|
|
26
|
+
- 旧版无签名文件在首次启动时一次性迁移为 v2;升级后再次出现的无签名/缺失文件视为篡改。
|
|
27
|
+
- 诚实边界:DSH 与插件同用户运行时,OS 只读位可被有意的进程绕过;但签名校验使**静默改名单不可能**,
|
|
28
|
+
篡改会被检测并锁定(需操作员重置)。强隔离需独立用户/服务账号(后续项)。
|
|
29
|
+
- **名单继承保证**:黑白名单与其 MAC 密钥位于 `$DSH_HOME/xclient/`(插件包之外),升级、重启、卸载重装都会原样继承;只有面板中的显式「移除 / 重置名单」会改变它。新增 `test/gpw-persistence.test.mjs`(5 项)覆盖。
|
|
30
|
+
- 测试:新增 test/gpw-hardening.test.mjs(8 项:签名往返、只读拒绝直写、MAC 不匹配/删除检测、legacy 标记、reset、密钥 sidecar)
|
|
31
|
+
与 test/gpw-tamper-gate.test.mjs(4 项:篡改 fail-closed → 409 → 重置恢复 → 内联审批放行;legacy 迁移;客户端内联提示/折叠/重置静态断言)。
|
|
32
|
+
reset、密钥 sidecar)。`client-bundle` 更新为五个 composer 控件;`gpw-activation` 用临时名单文件隔离,
|
|
33
|
+
不再写真实 `~/.dsh`。
|
|
34
|
+
- 证据:`gpw-hardening 8/8`、`gpw-folder-policy 9/9`、`gpw-activation 4/4`、`client-bundle 26/26`(node 进程内运行)。
|
|
35
|
+
|
|
10
36
|
## [1.7.8] — 2026-10-26
|
|
11
37
|
|
|
12
38
|
**GPW 启用失败修复(第二个):`TypeError: Cannot read properties of undefined (reading 'layers')`。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -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
|
+
}
|
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}',
|
|
@@ -545,6 +568,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
545
568
|
const [busy, setBusy] = React.useState(false);
|
|
546
569
|
const [manual, setManual] = React.useState('');
|
|
547
570
|
const [badPath, setBadPath] = React.useState(null);
|
|
571
|
+
const [expanded, setExpanded] = React.useState({});
|
|
548
572
|
const call = React.useCallback(async (url, init) => {
|
|
549
573
|
try {
|
|
550
574
|
const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
|
|
@@ -588,13 +612,37 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
588
612
|
setBusy(true);
|
|
589
613
|
try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
|
|
590
614
|
};
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
615
|
+
/** v1.7.9: after a tamper report the signed lists are locked until the operator resets them. */
|
|
616
|
+
const resetLists = async () => {
|
|
617
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function' && window.confirm(t('gpwResetConfirm')) !== true) return;
|
|
618
|
+
setBusy(true);
|
|
619
|
+
try { await call('/api/w2m/gpw/reset', { method: 'POST', body: JSON.stringify({ confirm: 'reset' }) }); await load(); } finally { setBusy(false); }
|
|
620
|
+
};
|
|
621
|
+
/** v1.7.9: lists show three rows by default; the rest fold behind one toggle. */
|
|
622
|
+
const foldItems = (items, key) => (expanded[key] === true ? items : items.slice(0, 3));
|
|
623
|
+
const foldRow = (items, key) => (items.length <= 3 ? null : h('button', {
|
|
624
|
+
key: 'fold-' + key,
|
|
625
|
+
type: 'button',
|
|
626
|
+
className: 'w2mGpw_btn',
|
|
627
|
+
disabled: busy,
|
|
628
|
+
onClick: () => setExpanded((prev) => ({ ...prev, [key]: prev[key] !== true })),
|
|
629
|
+
}, expanded[key] === true ? t('gpwFold') : t('gpwMore', { count: items.length - 3 })));
|
|
630
|
+
const allowWithFolder = async (item, list) => {
|
|
631
|
+
if (typeof item?.folder !== 'string' || item.folder === '') return;
|
|
632
|
+
const added = await addFolder(list, item.folder);
|
|
633
|
+
if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
|
|
634
|
+
};
|
|
635
|
+
const folderList = (title, list) => {
|
|
636
|
+
const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
|
|
637
|
+
return h('div', { key: list, className: 'w2mGpw_block' }, [
|
|
638
|
+
h('div', { key: 'title', className: 'w2mGpw_title' }, title),
|
|
639
|
+
...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
|
|
640
|
+
h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
|
|
641
|
+
h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
|
|
642
|
+
])),
|
|
643
|
+
foldRow(items, list),
|
|
644
|
+
]);
|
|
645
|
+
};
|
|
598
646
|
const recent = Array.isArray(state?.recent) ? state.recent : [];
|
|
599
647
|
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
600
648
|
return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
@@ -604,6 +652,12 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
604
652
|
h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
605
653
|
]),
|
|
606
654
|
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
|
|
655
|
+
state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
|
|
656
|
+
h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
|
|
657
|
+
h('div', { key: 'a', className: 'w2mGpw_actions' }, [
|
|
658
|
+
h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
|
|
659
|
+
]),
|
|
660
|
+
]) : null,
|
|
607
661
|
h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
|
|
608
662
|
h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
|
|
609
663
|
h('input', {
|
|
@@ -636,6 +690,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
636
690
|
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
|
|
637
691
|
]),
|
|
638
692
|
])),
|
|
693
|
+
foldRow(recent, 'recent'),
|
|
639
694
|
]),
|
|
640
695
|
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
641
696
|
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
@@ -645,6 +700,8 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
645
700
|
h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
|
|
646
701
|
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
|
|
647
702
|
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
|
|
703
|
+
item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
|
|
704
|
+
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
705
|
]),
|
|
649
706
|
])),
|
|
650
707
|
]),
|
|
@@ -652,22 +709,107 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
652
709
|
]);
|
|
653
710
|
}
|
|
654
711
|
|
|
655
|
-
/** The floating launcher: the panel
|
|
712
|
+
/** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
|
|
656
713
|
function GpwLauncher({ t }) {
|
|
657
714
|
const [open, setOpen] = React.useState(false);
|
|
715
|
+
const [pendingCount, setPendingCount] = React.useState(0);
|
|
716
|
+
React.useEffect(() => {
|
|
717
|
+
let alive = true;
|
|
718
|
+
const load = async () => {
|
|
719
|
+
try {
|
|
720
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
721
|
+
const body = response.ok ? await response.json() : null;
|
|
722
|
+
if (alive) setPendingCount(body?.ok === true && Array.isArray(body.pending) ? body.pending.length : 0);
|
|
723
|
+
} catch {
|
|
724
|
+
if (alive) setPendingCount(0);
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
load();
|
|
728
|
+
const timer = setInterval(load, 3000);
|
|
729
|
+
const onOpen = () => setOpen(true);
|
|
730
|
+
window.addEventListener('w2m-gpw-open', onOpen);
|
|
731
|
+
return () => {
|
|
732
|
+
alive = false;
|
|
733
|
+
clearInterval(timer);
|
|
734
|
+
window.removeEventListener('w2m-gpw-open', onOpen);
|
|
735
|
+
};
|
|
736
|
+
}, []);
|
|
658
737
|
return h(React.Fragment, null, [
|
|
659
738
|
h('button', {
|
|
660
739
|
key: 'launcher',
|
|
661
740
|
type: 'button',
|
|
662
741
|
className: 'w2mGpw_launcher',
|
|
663
|
-
title: t('gpwLauncher'),
|
|
742
|
+
title: pendingCount > 0 ? t('gpwPendingCount', { count: pendingCount }) : t('gpwLauncher'),
|
|
664
743
|
'aria-expanded': open,
|
|
665
744
|
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
666
|
-
},
|
|
745
|
+
}, [
|
|
746
|
+
t('gpwLauncher'),
|
|
747
|
+
pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
|
|
748
|
+
]),
|
|
667
749
|
open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
|
|
668
750
|
]);
|
|
669
751
|
}
|
|
670
752
|
|
|
753
|
+
/**
|
|
754
|
+
* v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
|
|
755
|
+
* (allow once / deny once / open the panel) instead of waiting silently for the operator.
|
|
756
|
+
*/
|
|
757
|
+
function GpwPrompt({ t }) {
|
|
758
|
+
const [state, setState] = React.useState(null);
|
|
759
|
+
const [busy, setBusy] = React.useState(false);
|
|
760
|
+
React.useEffect(() => {
|
|
761
|
+
let alive = true;
|
|
762
|
+
const load = async () => {
|
|
763
|
+
try {
|
|
764
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
765
|
+
const body = response.ok ? await response.json() : null;
|
|
766
|
+
if (alive) setState(body);
|
|
767
|
+
} catch {
|
|
768
|
+
if (alive) setState(null);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
load();
|
|
772
|
+
const timer = setInterval(load, 2000);
|
|
773
|
+
return () => {
|
|
774
|
+
alive = false;
|
|
775
|
+
clearInterval(timer);
|
|
776
|
+
};
|
|
777
|
+
}, []);
|
|
778
|
+
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
779
|
+
if (pending.length === 0) return null;
|
|
780
|
+
const item = pending[0];
|
|
781
|
+
const decide = async (decision) => {
|
|
782
|
+
setBusy(true);
|
|
783
|
+
try {
|
|
784
|
+
await fetch('/api/w2m/gpw/decide', {
|
|
785
|
+
method: 'POST',
|
|
786
|
+
credentials: 'same-origin',
|
|
787
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
788
|
+
body: JSON.stringify({ id: item.id, decision }),
|
|
789
|
+
});
|
|
790
|
+
} catch {
|
|
791
|
+
/* the next poll shows the queue again */
|
|
792
|
+
} finally {
|
|
793
|
+
setBusy(false);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
const openPanel = () => {
|
|
797
|
+
try {
|
|
798
|
+
window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
|
|
799
|
+
} catch {
|
|
800
|
+
/* no window in a headless render */
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
|
|
804
|
+
h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
|
|
805
|
+
? t('gpwPendingCount', { count: pending.length })
|
|
806
|
+
: t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
|
|
807
|
+
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
|
|
808
|
+
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
|
|
809
|
+
h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
|
|
810
|
+
]);
|
|
811
|
+
}
|
|
812
|
+
|
|
671
813
|
/** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
|
|
672
814
|
function PluginUpdate({ t }) {
|
|
673
815
|
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
@@ -1543,6 +1685,19 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1543
1685
|
} catch (error) {
|
|
1544
1686
|
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
|
1545
1687
|
}
|
|
1688
|
+
// v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
|
|
1689
|
+
// asks allow/deny immediately instead of waiting silently in the panel.
|
|
1690
|
+
try {
|
|
1691
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1692
|
+
name: 'conversation.input.left',
|
|
1693
|
+
id: 'w2m-gpw-prompt',
|
|
1694
|
+
order: 44,
|
|
1695
|
+
label: 'GPW pending prompt',
|
|
1696
|
+
inject: () => ({ t }),
|
|
1697
|
+
}, GpwPrompt));
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
|
|
1700
|
+
}
|
|
1546
1701
|
try {
|
|
1547
1702
|
slots.inject('conversation.input.left', () => slots.register({
|
|
1548
1703
|
name: 'conversation.input.left',
|
|
@@ -1601,6 +1756,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1601
1756
|
exports.inject = inject;
|
|
1602
1757
|
exports.TargetSelect = TargetSelect;
|
|
1603
1758
|
exports.GpwLauncher = GpwLauncher;
|
|
1759
|
+
exports.GpwPrompt = GpwPrompt;
|
|
1604
1760
|
exports.FleetChat = FleetChat;
|
|
1605
1761
|
exports.injectStyle = injectStyle;
|
|
1606
1762
|
exports.makeTranslator = makeTranslator;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.9",
|
|
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
|
|
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",
|
|
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",
|
|
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.
|
|
4
|
+
"version": "1.7.9",
|
|
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({
|
package/src/plugin/gpw.mjs
CHANGED
|
@@ -25,17 +25,19 @@
|
|
|
25
25
|
*
|
|
26
26
|
* ## Operator surface
|
|
27
27
|
*
|
|
28
|
-
* The panel is rendered by the w2m client (`lib/client.js`): a
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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';
|
|
41
43
|
|
|
@@ -49,11 +51,15 @@ export const GPW_ROUTE = '/api/w2m/gpw/state';
|
|
|
49
51
|
export const GPW_FOLDERS_ROUTE = '/api/w2m/gpw/folders';
|
|
50
52
|
export const GPW_DECIDE_ROUTE = '/api/w2m/gpw/decide';
|
|
51
53
|
export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
|
|
54
|
+
export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
|
|
52
55
|
|
|
53
56
|
const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
|
|
54
57
|
const DEFAULT_PENDING_TIMEOUT_MS = 120_000;
|
|
55
58
|
const RECENT_LIMIT = 20;
|
|
56
59
|
const NO_STORE = { 'cache-control': 'no-store' };
|
|
60
|
+
/** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
|
|
61
|
+
const FOLDERS_VERSION = 2;
|
|
62
|
+
const FOLDERS_MAC_ALGO = 'sha256';
|
|
57
63
|
|
|
58
64
|
/** Resolve the folders file: config first, then `$DSH_HOME/xclient/gpw-folders.json`. */
|
|
59
65
|
export function foldersFileOf(config = {}) {
|
|
@@ -62,28 +68,116 @@ export function foldersFileOf(config = {}) {
|
|
|
62
68
|
return join(home, 'xclient', 'gpw-folders.json');
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
71
|
+
/** The sidecar file that holds the MAC key. Kept next to (never inside) the lists file. */
|
|
72
|
+
export function foldersKeyFileOf(file) {
|
|
73
|
+
return `${file}.key`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the MAC key: explicit config / env first, else a generated 32-byte key in a 0600 sidecar.
|
|
78
|
+
* `created` is true only when this call generated the key, which is what lets a legacy plain-JSON
|
|
79
|
+
* file be migrated exactly once and a later unsigned file be treated as tampering instead.
|
|
80
|
+
*/
|
|
81
|
+
export function ensureFoldersKey(file, config = {}) {
|
|
82
|
+
const fromConfig = typeof config.foldersKey === 'string' && config.foldersKey.trim() !== '' ? config.foldersKey.trim() : null;
|
|
83
|
+
const fromEnv = typeof process.env.W2M_GPW_FOLDERS_KEY === 'string' && process.env.W2M_GPW_FOLDERS_KEY.trim() !== ''
|
|
84
|
+
? process.env.W2M_GPW_FOLDERS_KEY.trim()
|
|
85
|
+
: null;
|
|
86
|
+
const explicit = fromConfig ?? fromEnv;
|
|
87
|
+
if (explicit !== null) return { key: explicit, created: false, source: fromConfig !== null ? 'config' : 'env' };
|
|
88
|
+
const keyFile = foldersKeyFileOf(file);
|
|
67
89
|
try {
|
|
68
|
-
const
|
|
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
|
-
};
|
|
90
|
+
const existing = readFileSync(keyFile, 'utf8').trim();
|
|
91
|
+
if (existing !== '') return { key: existing, created: false, source: 'file' };
|
|
73
92
|
} catch {
|
|
74
|
-
|
|
93
|
+
/* generated below */
|
|
75
94
|
}
|
|
95
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
96
|
+
const key = randomBytes(32).toString('hex');
|
|
97
|
+
writeFileSync(keyFile, `${key}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
98
|
+
try { chmodSync(keyFile, 0o600); } catch { /* best effort on Windows */ }
|
|
99
|
+
return { key, created: true, source: 'file' };
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
/**
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
/** Canonical MAC over the two lists (order-independent, so adding/removing a row is what changes it). */
|
|
103
|
+
export function macOfFolders(whitelist, blacklist, key) {
|
|
104
|
+
const canonical = JSON.stringify({
|
|
105
|
+
whitelist: [...new Set(whitelist)].sort(),
|
|
106
|
+
blacklist: [...new Set(blacklist)].sort(),
|
|
107
|
+
});
|
|
108
|
+
return createHmac(FOLDERS_MAC_ALGO, key).update(canonical).digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the operator-managed lists.
|
|
113
|
+
*
|
|
114
|
+
* - missing file + `allowMissing` -> clean empty lists (first run)
|
|
115
|
+
* - missing file otherwise -> `tampered` (the signed file was deleted)
|
|
116
|
+
* - valid v2 + MAC -> lists
|
|
117
|
+
* - v2 with a bad MAC -> `tampered` (edited outside GPW)
|
|
118
|
+
* - no version/MAC (legacy) -> `legacy: true`; the caller migrates once or treats it as tampered
|
|
119
|
+
*/
|
|
120
|
+
export function readFolders(file, key = null, options = {}) {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(file, 'utf8');
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
|
|
126
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
|
|
127
|
+
}
|
|
128
|
+
if (error?.code !== 'ENOENT') {
|
|
129
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
|
|
130
|
+
}
|
|
131
|
+
return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
|
|
132
|
+
}
|
|
133
|
+
let parsed = null;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
|
|
138
|
+
}
|
|
139
|
+
const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
140
|
+
const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
141
|
+
if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
|
|
142
|
+
return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
|
|
143
|
+
}
|
|
144
|
+
if (key === null) return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
|
|
145
|
+
if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
|
|
146
|
+
return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
|
|
147
|
+
}
|
|
148
|
+
return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Persist the operator-managed lists.
|
|
153
|
+
*
|
|
154
|
+
* Atomic (tmp + rename), MAC-signed when a key is supplied, and left **read-only** (0444) so an
|
|
155
|
+
* accidental or casual edit from the agent's file tools fails instead of silently changing policy.
|
|
156
|
+
* GPW itself rewrites by clearing the flag, writing a new tmp file and renaming over the target.
|
|
157
|
+
*/
|
|
158
|
+
export function writeFolders(file, folders, key = null) {
|
|
159
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
160
|
+
const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
161
|
+
const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
162
|
+
const next = { version: FOLDERS_VERSION, whitelist, blacklist };
|
|
163
|
+
if (key !== null) {
|
|
164
|
+
next.algo = FOLDERS_MAC_ALGO;
|
|
165
|
+
next.mac = macOfFolders(whitelist, blacklist, key);
|
|
166
|
+
}
|
|
167
|
+
const payload = `${JSON.stringify(next, null, 2)}\n`;
|
|
168
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
169
|
+
// Windows refuses to rename over a read-only target: clear it first, then the atomic replace runs.
|
|
170
|
+
try { chmodSync(file, 0o644); } catch { /* first write */ }
|
|
171
|
+
writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
172
|
+
try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
|
|
173
|
+
renameSync(tmp, file);
|
|
174
|
+
try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
|
|
175
|
+
return { whitelist, blacklist };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
|
|
179
|
+
export function resetFolders(file, key) {
|
|
180
|
+
return writeFolders(file, { whitelist: [], blacklist: [] }, key);
|
|
87
181
|
}
|
|
88
182
|
|
|
89
183
|
/**
|
|
@@ -229,6 +323,33 @@ export function apply(ctx, config = {}) {
|
|
|
229
323
|
const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
|
|
230
324
|
const requireFullAccess = config.requireFullAccess !== false;
|
|
231
325
|
const foldersFile = foldersFileOf(config);
|
|
326
|
+
const keyInfo = ensureFoldersKey(foldersFile, config);
|
|
327
|
+
const foldersKey = keyInfo.key;
|
|
328
|
+
/** Set once the signed file has been read cleanly; a later disappearance then reads as tampering. */
|
|
329
|
+
let foldersSeen = false;
|
|
330
|
+
/** Non-null until the operator resets the lists; class-B/C paths are locked while it is set. */
|
|
331
|
+
let tamperedReason = null;
|
|
332
|
+
|
|
333
|
+
const noteTamper = (reason) => {
|
|
334
|
+
const next = reason ?? 'folder lists failed integrity check';
|
|
335
|
+
if (tamperedReason === null) ctx.logger?.warn?.(`dsh-gpw: ${next}; class-B/C paths are locked until the operator resets the lists`);
|
|
336
|
+
tamperedReason = next;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// First read: migrate a legacy plain-JSON file exactly once; after that an unsigned/missing file is tampering.
|
|
340
|
+
const initialFolders = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created || !existsSync(foldersFile) });
|
|
341
|
+
if (initialFolders.legacy) ctx.logger?.info?.('dsh-gpw: migrating legacy folder lists to MAC-signed v2 (read-only)');
|
|
342
|
+
if (initialFolders.tampered) {
|
|
343
|
+
noteTamper(initialFolders.reason);
|
|
344
|
+
} else {
|
|
345
|
+
// Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
|
|
346
|
+
try {
|
|
347
|
+
writeFolders(foldersFile, initialFolders, foldersKey);
|
|
348
|
+
foldersSeen = true;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
noteTamper(`folder lists could not be initialized: ${error?.message ?? error}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
232
353
|
|
|
233
354
|
/** Timestamps of auto-approvals in the current window. */
|
|
234
355
|
const approvals = [];
|
|
@@ -247,11 +368,20 @@ export function apply(ctx, config = {}) {
|
|
|
247
368
|
};
|
|
248
369
|
|
|
249
370
|
const folderLists = () => {
|
|
250
|
-
const stored = readFolders(foldersFile);
|
|
371
|
+
const stored = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created && !foldersSeen });
|
|
372
|
+
if (stored.legacy) {
|
|
373
|
+
noteTamper('folder lists lost their integrity signature (edited outside GPW?)');
|
|
374
|
+
} else if (stored.tampered) {
|
|
375
|
+
noteTamper(stored.reason);
|
|
376
|
+
} else {
|
|
377
|
+
foldersSeen = true;
|
|
378
|
+
}
|
|
251
379
|
const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
|
|
252
380
|
return {
|
|
253
381
|
whitelist: merge(config.whitelist, stored.whitelist),
|
|
254
382
|
blacklist: merge(config.blacklist, stored.blacklist),
|
|
383
|
+
tampered: tamperedReason !== null,
|
|
384
|
+
reason: tamperedReason,
|
|
255
385
|
};
|
|
256
386
|
};
|
|
257
387
|
|
|
@@ -335,8 +465,17 @@ export function apply(ctx, config = {}) {
|
|
|
335
465
|
const blacklistGuard = () => (exec) => {
|
|
336
466
|
if (!isFullAccess(exec?.agent)) return undefined;
|
|
337
467
|
const lists = folderLists();
|
|
338
|
-
if (lists.blacklist.length === 0) return undefined;
|
|
339
468
|
const call = toolCallOf(exec);
|
|
469
|
+
if (lists.tampered) {
|
|
470
|
+
// Fail closed: while the lists cannot be trusted, every path-naming call is refused outright.
|
|
471
|
+
const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
|
|
472
|
+
if (probe.paths.length === 0) return undefined;
|
|
473
|
+
deniedTotal += 1;
|
|
474
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
|
|
475
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
476
|
+
return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
|
|
477
|
+
}
|
|
478
|
+
if (lists.blacklist.length === 0) return undefined;
|
|
340
479
|
const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
|
|
341
480
|
if (result.decision !== 'deny') return undefined;
|
|
342
481
|
deniedTotal += 1;
|
|
@@ -372,15 +511,26 @@ export function apply(ctx, config = {}) {
|
|
|
372
511
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
373
512
|
if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
|
|
374
513
|
const lists = folderLists();
|
|
375
|
-
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
376
514
|
const call = toolCallOf(exec);
|
|
515
|
+
if (lists.tampered) {
|
|
516
|
+
// Fail closed: a tampered list file must not be able to silently turn policy off.
|
|
517
|
+
const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
|
|
518
|
+
if (probe.paths.length === 0) return next();
|
|
519
|
+
deniedTotal += 1;
|
|
520
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
|
|
521
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
522
|
+
return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
|
|
523
|
+
}
|
|
524
|
+
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
377
525
|
let cwd = null;
|
|
378
526
|
try {
|
|
379
527
|
cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
380
528
|
} catch {
|
|
381
529
|
cwd = null;
|
|
382
530
|
}
|
|
383
|
-
|
|
531
|
+
// A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
|
|
532
|
+
// subfolder reference is classified rather than treated as "names no path".
|
|
533
|
+
const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
|
|
384
534
|
if (classification.decision === 'pass' && classification.paths.length === 0) return next();
|
|
385
535
|
const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
|
|
386
536
|
const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
|
|
@@ -410,28 +560,34 @@ export function apply(ctx, config = {}) {
|
|
|
410
560
|
path: GPW_ROUTE,
|
|
411
561
|
methods: ['GET'],
|
|
412
562
|
requestBody: 'buffered',
|
|
413
|
-
fetch: async () =>
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
563
|
+
fetch: async () => {
|
|
564
|
+
const lists = folderLists();
|
|
565
|
+
return json({
|
|
566
|
+
ok: true,
|
|
567
|
+
enabled: true,
|
|
568
|
+
require_full_access: requireFullAccess,
|
|
569
|
+
folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
|
|
570
|
+
tampered: lists.tampered,
|
|
571
|
+
tampered_reason: lists.reason,
|
|
572
|
+
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
|
|
573
|
+
recent: recent.map((entry) => ({
|
|
574
|
+
at: entry.at,
|
|
575
|
+
tool: entry.tool,
|
|
576
|
+
decision: entry.decision,
|
|
577
|
+
reason: entry.reason,
|
|
578
|
+
paths: entry.paths,
|
|
579
|
+
folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
|
|
580
|
+
})),
|
|
581
|
+
pending: [...pending.entries()].map(([id, entry]) => ({
|
|
582
|
+
id,
|
|
583
|
+
tool: entry.tool,
|
|
584
|
+
reason: entry.reason,
|
|
585
|
+
paths: entry.paths,
|
|
586
|
+
folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
|
|
587
|
+
pending_ms: Date.now() - entry.createdAt,
|
|
588
|
+
})),
|
|
589
|
+
});
|
|
590
|
+
},
|
|
435
591
|
});
|
|
436
592
|
|
|
437
593
|
registry.register({
|
|
@@ -448,23 +604,58 @@ export function apply(ctx, config = {}) {
|
|
|
448
604
|
const list = body?.list;
|
|
449
605
|
if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
|
|
450
606
|
if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
|
|
607
|
+
const current = folderLists();
|
|
608
|
+
if (current.tampered) {
|
|
609
|
+
return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
|
|
610
|
+
}
|
|
611
|
+
const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
|
|
451
612
|
if (body.action === 'remove') {
|
|
452
613
|
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
614
|
const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
|
|
455
|
-
|
|
615
|
+
try {
|
|
616
|
+
writeFolders(foldersFile, next, foldersKey);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
|
|
619
|
+
}
|
|
456
620
|
return json({ ok: true, folders: folderLists() });
|
|
457
621
|
}
|
|
458
622
|
const validated = validateFolderInput(body?.path);
|
|
459
623
|
if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
|
|
460
|
-
const stored = readFolders(foldersFile);
|
|
461
624
|
const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
|
|
462
|
-
|
|
625
|
+
try {
|
|
626
|
+
writeFolders(foldersFile, next, foldersKey);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
|
|
629
|
+
}
|
|
463
630
|
ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
|
|
464
631
|
return json({ ok: true, folders: folderLists() });
|
|
465
632
|
},
|
|
466
633
|
});
|
|
467
634
|
|
|
635
|
+
registry.register({
|
|
636
|
+
path: GPW_RESET_ROUTE,
|
|
637
|
+
methods: ['POST'],
|
|
638
|
+
requestBody: 'buffered',
|
|
639
|
+
fetch: async (request) => {
|
|
640
|
+
let body = null;
|
|
641
|
+
try {
|
|
642
|
+
body = await request.json();
|
|
643
|
+
} catch {
|
|
644
|
+
/* refused below */
|
|
645
|
+
}
|
|
646
|
+
if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
|
|
647
|
+
try {
|
|
648
|
+
resetFolders(foldersFile, foldersKey);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
|
|
651
|
+
}
|
|
652
|
+
tamperedReason = null;
|
|
653
|
+
foldersSeen = true;
|
|
654
|
+
ctx.logger?.warn?.('dsh-gpw: folder lists were reset by the operator; all entries cleared and re-signed');
|
|
655
|
+
return json({ ok: true, tampered: false, folders: folderLists() });
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
468
659
|
registry.register({
|
|
469
660
|
path: GPW_DECIDE_ROUTE,
|
|
470
661
|
methods: ['POST'],
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
286
|
+
const PLUGIN_VERSION = '1.7.9';
|
|
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;
|