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