@twinsearth/w2m-dsh-plugin 1.7.9 → 1.8.1
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 +23 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.8.0/GPW-COLORS.json +24 -0
- package/docs/acceptance/1.8.1/GPW-RECENT-FOLD.json +21 -0
- package/lib/client.js +34 -10
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/plugin/gpw-colors.mjs +144 -0
- package/src/plugin/gpw.mjs +103 -19
- 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.
|
|
8
|
+
"version": "1.8.1"
|
|
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.
|
|
15
|
+
"version": "1.8.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.
|
|
29
|
+
"version": "1.8.1"
|
|
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.
|
|
4
|
+
"version": "1.8.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.8.1] — 2026-10-26
|
|
11
|
+
|
|
12
|
+
**GPW「最近被拦截」去噪:已在名单中的条目折叠显示;加入名单后条目立即消失。**
|
|
13
|
+
|
|
14
|
+
- 状态接口为每条 recent 增加 `folder_in`(whitelist / blacklist / null):文件夹已被名单覆盖的拦截记录不再占用主列表。
|
|
15
|
+
- 面板把已覆盖条目折叠为「已在名单中 N 条 · 展开/收起」,展开后只读展示(不重复给加入按钮)。
|
|
16
|
+
- 从最近拦截一键加入白/黑名单时,服务端同步裁剪该条目(以及被同一父目录覆盖的条目),下一次轮询不再出现。
|
|
17
|
+
- 新增 `test/gpw-recent-fold.test.mjs`(4 项:覆盖归属、嵌套覆盖、客户端折叠静态断言、状态接口字段)。
|
|
18
|
+
- 证据:`docs/acceptance/1.8.1/GPW-RECENT-FOLD.json`。
|
|
19
|
+
## [1.8.0] — 2026-10-26
|
|
20
|
+
|
|
21
|
+
**GPW 增加四色审核 / 标记 / 提醒系统:绿(通过)、蓝(正常)、黄(警报)、红(风险)。**
|
|
22
|
+
|
|
23
|
+
- 新增 `src/plugin/gpw-colors.mjs`:把 DSH 各插件的审核行为归位到四色(只读/L1-L2/白名单命中/allow-list = 绿;
|
|
24
|
+
本会话已批准过(指纹授权)= 蓝;越界写、非白名单命令、中风险 git/npm/网络操作、未知工具 = 黄;
|
|
25
|
+
危险命令黑名单、敏感路径、系统路径、显式 deny 目录 = 红)。
|
|
26
|
+
- 门控改为颜色决策:绿/蓝直接放行(蓝来自会话内批准后的操作指纹);黄进入 GPW 待审批队列;红直接拒绝
|
|
27
|
+
(`tools.guard` + `tools/pre-execute` 双保险,拒绝理由带颜色语义)。
|
|
28
|
+
- 标记与提醒:`/api/w2m/gpw/state` 新增四色计数(colors)、待审批项与最近拦截项的颜色字段;
|
|
29
|
+
面板显示四色图例与计数,待审批/最近条目带颜色标记;「允许本次会话」动作(allow-session)批准后,
|
|
30
|
+
同一操作后续以蓝色(已授权常态)放行。
|
|
31
|
+
- 一句话语义:绿=不用管,蓝=管过了,黄=现在管,红=管也不让过(或必须人工硬确认)。
|
|
32
|
+
- 证据:`docs/acceptance/1.8.0/GPW-COLORS.json`。
|
|
10
33
|
## [1.7.9] — 2026-10-26
|
|
11
34
|
|
|
12
35
|
**GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.8.1",
|
|
3
|
+
"case": "GPW-RECENT-FOLD",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-26T06:55:26.042Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "local-gpw-fold-20261026065526",
|
|
11
|
+
"requirement": "recently-intercepted rows already covered by a list are collapsed, and a row the operator adds to a list disappears from the list immediately",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"folder_in": "the state route annotates each recent row with whitelist / blacklist / null",
|
|
16
|
+
"collapse": "the panel shows covered rows behind \"已在名单中 N 条 · 展开/收起\" and they carry no add buttons",
|
|
17
|
+
"disappear": "adding a folder from a recent row prunes that row and every row inside the folder server-side",
|
|
18
|
+
"tests": "test/gpw-recent-fold.test.mjs 4/4"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -439,6 +439,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
439
439
|
'.w2mGpw_row{display:flex;align-items:center;gap:6px;min-width:0}',
|
|
440
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)}',
|
|
441
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}',
|
|
442
449
|
'.w2mGpw_actions{display:flex;flex-wrap:wrap;gap:6px}',
|
|
443
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}',
|
|
444
451
|
'.w2mGpw_btn:disabled{opacity:.5;cursor:default}',
|
|
@@ -562,7 +569,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
562
569
|
* ways: typed here and validated by the host, or taken from a recently intercepted call in one click. The lists
|
|
563
570
|
* live in $DSH_HOME/xclient/gpw-folders.json; no profile YAML is edited by this feature.
|
|
564
571
|
*/
|
|
565
|
-
function GpwPanel({ t, onClose }) {
|
|
572
|
+
function GpwPanel({ t, sessionId, onClose }) {
|
|
566
573
|
const [state, setState] = React.useState(null);
|
|
567
574
|
const [error, setError] = React.useState(null);
|
|
568
575
|
const [busy, setBusy] = React.useState(false);
|
|
@@ -643,8 +650,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
643
650
|
foldRow(items, list),
|
|
644
651
|
]);
|
|
645
652
|
};
|
|
646
|
-
const
|
|
647
|
-
const
|
|
653
|
+
const recentAll = Array.isArray(state?.recent) ? state.recent : [];
|
|
654
|
+
const recent = recentAll.filter((item) => item?.folder_in === null || item?.folder_in === undefined);
|
|
655
|
+
const recentInLists = recentAll.filter((item) => item?.folder_in === 'whitelist' || item?.folder_in === 'blacklist');
|
|
656
|
+
const [showRecentInLists, setShowRecentInLists] = React.useState(false);
|
|
657
|
+
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
658
|
+
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
659
|
+
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
648
660
|
return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
649
661
|
h('div', { key: 'card', className: 'w2mGpw_card' }, [
|
|
650
662
|
h('div', { key: 'head', className: 'w2mGpw_head' }, [
|
|
@@ -658,6 +670,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
658
670
|
h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
|
|
659
671
|
]),
|
|
660
672
|
]) : null,
|
|
673
|
+
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)]))),
|
|
661
674
|
h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
|
|
662
675
|
h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
|
|
663
676
|
h('input', {
|
|
@@ -682,7 +695,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
682
695
|
recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
|
|
683
696
|
...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
|
|
684
697
|
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
685
|
-
h('span', { key: 'd', className: '
|
|
698
|
+
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
|
|
686
699
|
h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
|
|
687
700
|
]),
|
|
688
701
|
item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
|
|
@@ -692,10 +705,19 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
692
705
|
])),
|
|
693
706
|
foldRow(recent, 'recent'),
|
|
694
707
|
]),
|
|
708
|
+
recentInLists.length === 0 ? null : h('div', { key: 'recentInLists', className: 'w2mGpw_block' }, [
|
|
709
|
+
h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', 'aria-expanded': showRecentInLists, onClick: () => setShowRecentInLists((value) => !value) }, (showRecentInLists ? '收起' : '展开') + ' · 已在名单中 ' + recentInLists.length + ' 条'),
|
|
710
|
+
...(showRecentInLists ? recentInLists.map((item, index) => h('div', { key: 'inlist' + index, className: 'w2mGpw_recent' }, [
|
|
711
|
+
h('div', { key: 'r1', className: 'w2mGpw_row' }, [
|
|
712
|
+
h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green' }),
|
|
713
|
+
h('span', { key: 't', className: 'w2mGpw_path' }, (item.folder ?? '') + ' · 已加入' + (item.folder_in === 'whitelist' ? '白名单' : '黑名单')),
|
|
714
|
+
]),
|
|
715
|
+
])) : []),
|
|
716
|
+
]),
|
|
695
717
|
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
696
718
|
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
697
719
|
...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
|
|
698
|
-
h('div', { key: 'p1', className: 'w2mGpw_path' }, item.tool + ' · ' + (item.reason ?? '')),
|
|
720
|
+
h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
|
|
699
721
|
...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
|
|
700
722
|
h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
|
|
701
723
|
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
|
|
@@ -710,7 +732,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
710
732
|
}
|
|
711
733
|
|
|
712
734
|
/** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
|
|
713
|
-
function GpwLauncher({ t }) {
|
|
735
|
+
function GpwLauncher({ t, sessionId }) {
|
|
714
736
|
const [open, setOpen] = React.useState(false);
|
|
715
737
|
const [pendingCount, setPendingCount] = React.useState(0);
|
|
716
738
|
React.useEffect(() => {
|
|
@@ -746,7 +768,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
746
768
|
t('gpwLauncher'),
|
|
747
769
|
pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
|
|
748
770
|
]),
|
|
749
|
-
open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
|
|
771
|
+
open ? h(GpwPanel, { key: 'panel', t, sessionId, onClose: () => setOpen(false) }) : null,
|
|
750
772
|
]);
|
|
751
773
|
}
|
|
752
774
|
|
|
@@ -754,7 +776,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
754
776
|
* v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
|
|
755
777
|
* (allow once / deny once / open the panel) instead of waiting silently for the operator.
|
|
756
778
|
*/
|
|
757
|
-
function GpwPrompt({ t }) {
|
|
779
|
+
function GpwPrompt({ t, sessionId }) {
|
|
758
780
|
const [state, setState] = React.useState(null);
|
|
759
781
|
const [busy, setBusy] = React.useState(false);
|
|
760
782
|
React.useEffect(() => {
|
|
@@ -775,7 +797,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
775
797
|
clearInterval(timer);
|
|
776
798
|
};
|
|
777
799
|
}, []);
|
|
778
|
-
const
|
|
800
|
+
const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
|
|
801
|
+
// v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
|
|
802
|
+
const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
|
|
779
803
|
if (pending.length === 0) return null;
|
|
780
804
|
const item = pending[0];
|
|
781
805
|
const decide = async (decision) => {
|
|
@@ -1680,7 +1704,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1680
1704
|
id: 'w2m-gpw',
|
|
1681
1705
|
order: 43,
|
|
1682
1706
|
label: 'GPW folders',
|
|
1683
|
-
inject: () => ({ t }),
|
|
1707
|
+
inject: (sessionId) => ({ t, sessionId }),
|
|
1684
1708
|
}, GpwLauncher));
|
|
1685
1709
|
} catch (error) {
|
|
1686
1710
|
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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 test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.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 test/gpw-recent-fold.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 test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.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 test/gpw-recent-fold.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.
|
|
4
|
+
"version": "1.8.1",
|
|
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": [
|
|
@@ -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
|
+
}
|
package/src/plugin/gpw.mjs
CHANGED
|
@@ -39,10 +39,51 @@ import { homedir } from 'node:os';
|
|
|
39
39
|
import { dirname, join, resolve } from 'node:path';
|
|
40
40
|
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
41
41
|
|
|
42
|
-
import { classifyToolCall } from './folder-policy.mjs';
|
|
42
|
+
import { classifyToolCall, isInside, normalizeFolder } from './folder-policy.mjs';
|
|
43
|
+
import { classifyGpwCall, fingerprintOf } from './gpw-colors.mjs';
|
|
43
44
|
|
|
44
45
|
export const name = 'dsh-gpw';
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Which list (if any) already covers a folder, for the "recently intercepted" fold.
|
|
49
|
+
*
|
|
50
|
+
* A recent row whose folder is already covered by a list is noise: the operator already made that decision. Those rows
|
|
51
|
+
* are collapsed in the panel, and adding a folder from a recent row removes the row outright.
|
|
52
|
+
*/
|
|
53
|
+
export function folderInLists(folder, lists) {
|
|
54
|
+
if (typeof folder !== 'string' || folder.trim() === '') return null;
|
|
55
|
+
let target;
|
|
56
|
+
try {
|
|
57
|
+
target = normalizeFolder(folder);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const inList = (entries) => (Array.isArray(entries) ? entries : []).some((entry) => {
|
|
62
|
+
try {
|
|
63
|
+
const root = normalizeFolder(entry);
|
|
64
|
+
return isInside(target, root) || isInside(root, target);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (inList(lists?.whitelist)) return 'whitelist';
|
|
70
|
+
if (inList(lists?.blacklist)) return 'blacklist';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when a recent row's folder is the folder just added, or lives inside it. */
|
|
75
|
+
export function foldIsCoveredBy(addedFolder, candidate) {
|
|
76
|
+
if (typeof addedFolder !== 'string' || typeof candidate !== 'string') return false;
|
|
77
|
+
try {
|
|
78
|
+
const added = normalizeFolder(addedFolder);
|
|
79
|
+
const target = normalizeFolder(candidate);
|
|
80
|
+
return target === added || isInside(target, added);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
46
87
|
/** Services this plugin cannot work without. `connection` is used only for the operator routes. */
|
|
47
88
|
export const inject = ['permissionPresets'];
|
|
48
89
|
|
|
@@ -361,6 +402,10 @@ export function apply(ctx, config = {}) {
|
|
|
361
402
|
const pending = new Map();
|
|
362
403
|
/** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
|
|
363
404
|
const recent = [];
|
|
405
|
+
/** Four-colour counters: every decision lands in exactly one bucket (v1.7.10). */
|
|
406
|
+
const colorCounts = { green: 0, blue: 0, yellow: 0, red: 0 };
|
|
407
|
+
/** Operation fingerprints approved earlier in this session -> the blue ("正常") state. */
|
|
408
|
+
const sessionGrants = new Set();
|
|
364
409
|
|
|
365
410
|
const noteRecent = (entry) => {
|
|
366
411
|
recent.unshift({ at: Date.now(), ...entry });
|
|
@@ -419,6 +464,9 @@ export function apply(ctx, config = {}) {
|
|
|
419
464
|
tool: String(toolCallOf(exec).name ?? 'tool'),
|
|
420
465
|
reason,
|
|
421
466
|
paths,
|
|
467
|
+
color: 'yellow',
|
|
468
|
+
fingerprint: fingerprintOf(toolCallOf(exec)),
|
|
469
|
+
sessionId: String(exec?.agent?.session?.header?.id ?? ''),
|
|
422
470
|
};
|
|
423
471
|
pending.set(id, entry);
|
|
424
472
|
pendingTotal += 1;
|
|
@@ -441,9 +489,10 @@ export function apply(ctx, config = {}) {
|
|
|
441
489
|
} catch {
|
|
442
490
|
/* already fired */
|
|
443
491
|
}
|
|
444
|
-
if (decision === 'allow') {
|
|
492
|
+
if (decision === 'allow' || decision === 'allow-session') {
|
|
445
493
|
allowedTotal += 1;
|
|
446
|
-
entry.
|
|
494
|
+
if (typeof entry.fingerprint === 'string' && entry.fingerprint !== '') sessionGrants.add(entry.fingerprint);
|
|
495
|
+
entry.resolve({ kind: 'allow', reason: decision === 'allow-session' ? 'GPW: allowed for this session (blue)' : 'GPW: allowed by the operator' });
|
|
447
496
|
} else {
|
|
448
497
|
deniedTotal += 1;
|
|
449
498
|
entry.resolve({ kind: 'deny', reason: 'GPW: denied by the operator' });
|
|
@@ -521,7 +570,6 @@ export function apply(ctx, config = {}) {
|
|
|
521
570
|
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
522
571
|
return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
|
|
523
572
|
}
|
|
524
|
-
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
525
573
|
let cwd = null;
|
|
526
574
|
try {
|
|
527
575
|
cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
@@ -531,21 +579,52 @@ export function apply(ctx, config = {}) {
|
|
|
531
579
|
// A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
|
|
532
580
|
// subfolder reference is classified rather than treated as "names no path".
|
|
533
581
|
const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
|
|
534
|
-
|
|
582
|
+
// v1.7.10: one four-colour decision in front of the folder plan, so every audit behaviour in DSH has one home.
|
|
583
|
+
const fingerprint = fingerprintOf(call);
|
|
584
|
+
const colour = classifyGpwCall({
|
|
585
|
+
name: call.name,
|
|
586
|
+
arguments: call.arguments,
|
|
587
|
+
paths: classification.paths,
|
|
588
|
+
whitelist: lists.whitelist,
|
|
589
|
+
blacklist: lists.blacklist,
|
|
590
|
+
workspace: cwd ?? null,
|
|
591
|
+
authorized: sessionGrants.has(fingerprint),
|
|
592
|
+
});
|
|
593
|
+
if (colour.color === 'red') {
|
|
594
|
+
deniedTotal += 1;
|
|
595
|
+
colorCounts.red += 1;
|
|
596
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: colour.reason, paths: classification.paths });
|
|
597
|
+
ctx.logger?.warn?.(`dsh-gpw: red -- refused ${String(call.name ?? 'tool')} -- ${colour.reason}`);
|
|
598
|
+
return { kind: 'deny', reason: `GPW red: ${colour.reason}` };
|
|
599
|
+
}
|
|
600
|
+
if (colour.color === 'green' || colour.color === 'blue') {
|
|
601
|
+
colorCounts[colour.color] += 1;
|
|
602
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: colour.color, reason: colour.reason, paths: classification.paths });
|
|
603
|
+
ctx.logger?.info?.(`dsh-gpw: ${colour.color} -- ${String(call.name ?? 'tool')} -- ${colour.reason}`);
|
|
604
|
+
return { kind: 'allow' };
|
|
605
|
+
}
|
|
606
|
+
if (classification.decision === 'pass' && classification.paths.length === 0) {
|
|
607
|
+
colorCounts.yellow += 1;
|
|
608
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: colour.reason, paths: [] });
|
|
609
|
+
return waitForOperator(exec, colour.reason, []);
|
|
610
|
+
}
|
|
535
611
|
const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
|
|
536
612
|
const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
|
|
537
613
|
if (plan.kind === 'deny') {
|
|
538
614
|
deniedTotal += 1;
|
|
539
|
-
|
|
615
|
+
colorCounts.red += 1;
|
|
616
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: plan.reason, paths: classification.paths });
|
|
540
617
|
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
|
|
541
618
|
return { kind: 'deny', reason: `GPW: ${plan.reason}` };
|
|
542
619
|
}
|
|
543
620
|
if (plan.kind === 'allow') {
|
|
544
|
-
|
|
621
|
+
colorCounts.green += 1;
|
|
622
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: 'green', reason: plan.reason, paths: classification.paths });
|
|
545
623
|
ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
|
|
546
624
|
return { kind: 'allow' };
|
|
547
625
|
}
|
|
548
|
-
|
|
626
|
+
colorCounts.yellow += 1;
|
|
627
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: plan.reason, paths: classification.paths });
|
|
549
628
|
ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
|
|
550
629
|
return waitForOperator(exec, plan.reason, classification.paths);
|
|
551
630
|
});
|
|
@@ -569,15 +648,20 @@ export function apply(ctx, config = {}) {
|
|
|
569
648
|
folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
|
|
570
649
|
tampered: lists.tampered,
|
|
571
650
|
tampered_reason: lists.reason,
|
|
572
|
-
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
|
|
573
|
-
recent: recent.map((entry) =>
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
651
|
+
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
|
|
652
|
+
recent: recent.map((entry) => {
|
|
653
|
+
const folder = entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null;
|
|
654
|
+
return {
|
|
655
|
+
at: entry.at,
|
|
656
|
+
tool: entry.tool,
|
|
657
|
+
decision: entry.decision,
|
|
658
|
+
color: entry.color ?? null,
|
|
659
|
+
reason: entry.reason,
|
|
660
|
+
paths: entry.paths,
|
|
661
|
+
folder,
|
|
662
|
+
folder_in: folderInLists(folder, lists),
|
|
663
|
+
};
|
|
664
|
+
}),
|
|
581
665
|
pending: [...pending.entries()].map(([id, entry]) => ({
|
|
582
666
|
id,
|
|
583
667
|
tool: entry.tool,
|
|
@@ -667,8 +751,8 @@ export function apply(ctx, config = {}) {
|
|
|
667
751
|
} catch {
|
|
668
752
|
/* refused below */
|
|
669
753
|
}
|
|
670
|
-
if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny')) {
|
|
671
|
-
return json({ ok: false, error: 'id and decision (allow|deny) are required' }, 400);
|
|
754
|
+
if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny' && body?.decision !== 'allow-session')) {
|
|
755
|
+
return json({ ok: false, error: 'id and decision (allow|allow-session|deny) are required' }, 400);
|
|
672
756
|
}
|
|
673
757
|
const result = decidePending(body.id, body.decision);
|
|
674
758
|
return json(result.ok ? { ok: true } : { ok: false, error: result.error }, result.ok ? 200 : 404);
|
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.
|
|
286
|
+
const PLUGIN_VERSION = '1.8.1';
|
|
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;
|