@twinsearth/w2m-dsh-plugin 1.7.7 → 1.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +6 -0
- package/docs/acceptance/1.7.8/GPW-AGENT-SCOPE.json +21 -0
- package/docs/acceptance/1.7.9/GPW-HARDENING.json +26 -0
- package/lib/client.js +166 -10
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/plugin/folder-policy.mjs +16 -4
- package/src/plugin/gpw.mjs +280 -62
- package/src/plugin/tools.mjs +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.7.
|
|
8
|
+
"version": "1.7.9"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.7.
|
|
15
|
+
"version": "1.7.9",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.7.
|
|
29
|
+
"version": "1.7.9"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.9",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.7.9] — 2026-10-26
|
|
11
|
+
|
|
12
|
+
**GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
|
|
13
|
+
|
|
14
|
+
- **名单策略调整:文件夹包含子文件夹**:名单条目按「根目录 + 全部子文件夹」递归匹配(`E:\work` 覆盖 `E:\work\a\b\c.txt`,`E:\workshop` 不会被误匹配;嵌套黑名单仍优先);命令工具里的相对路径按会话工作区解析后再分类。新增测试覆盖递归覆盖、同前缀兄弟目录与相对路径。
|
|
15
|
+
- **发布门禁稳定性**:`test/mcp-http-live.test.mjs` 的 relay 实连用例在出口网络不可达(或 MCP 工具层报 "could not reach the relay")时明确 `skip`,网络抖动不再把 release 门禁判红;其余用例仍覆盖本地绑定行为。
|
|
16
|
+
- **拦截即审批(不再静默挂起)**:`tools/pre-execute` 挂起 C 类调用后,客户端在 **composer 行**
|
|
17
|
+
(`conversation.input.left`)新增内联提示 `GpwPrompt`:显示工具名与「允许本次 / 拒绝本次 / 打开面板」;
|
|
18
|
+
多个待审批显示数量。浮窗按钮增加待审批数量徽标,`w2m-gpw-open` 事件让内联提示直接打开面板。
|
|
19
|
+
- **名单折叠**:白名单、黑名单、最近被拦截默认各显示 3 条,其余通过「另有 N 个,展开 / 收起」折叠;
|
|
20
|
+
待审批项新增「加入白名单并允许」「加入黑名单并拒绝」一键动作(服务端 pending 项补充 `folder`)。
|
|
21
|
+
- **名单文件防篡改(fail-closed)**:`$DSH_HOME/xclient/gpw-folders.json` 改为 **MAC 签名 v2(HMAC-SHA256)
|
|
22
|
+
+ 原子写(tmp→rename)+ 写后只读 0444**。密钥在旁路 `*.key`(0600;或配置 `foldersKey` /
|
|
23
|
+
`W2M_GPW_FOLDERS_KEY`)。
|
|
24
|
+
- 外部修改(MAC 不匹配)、删除签名文件、去掉签名 → 状态 `tampered`,门控 **fail-closed**:所有命名路径的
|
|
25
|
+
调用被直接拒绝;面板显示警告并提供「重置名单(清空并重新签名)」。
|
|
26
|
+
- 旧版无签名文件在首次启动时一次性迁移为 v2;升级后再次出现的无签名/缺失文件视为篡改。
|
|
27
|
+
- 诚实边界:DSH 与插件同用户运行时,OS 只读位可被有意的进程绕过;但签名校验使**静默改名单不可能**,
|
|
28
|
+
篡改会被检测并锁定(需操作员重置)。强隔离需独立用户/服务账号(后续项)。
|
|
29
|
+
- **名单继承保证**:黑白名单与其 MAC 密钥位于 `$DSH_HOME/xclient/`(插件包之外),升级、重启、卸载重装都会原样继承;只有面板中的显式「移除 / 重置名单」会改变它。新增 `test/gpw-persistence.test.mjs`(5 项)覆盖。
|
|
30
|
+
- 测试:新增 test/gpw-hardening.test.mjs(8 项:签名往返、只读拒绝直写、MAC 不匹配/删除检测、legacy 标记、reset、密钥 sidecar)
|
|
31
|
+
与 test/gpw-tamper-gate.test.mjs(4 项:篡改 fail-closed → 409 → 重置恢复 → 内联审批放行;legacy 迁移;客户端内联提示/折叠/重置静态断言)。
|
|
32
|
+
reset、密钥 sidecar)。`client-bundle` 更新为五个 composer 控件;`gpw-activation` 用临时名单文件隔离,
|
|
33
|
+
不再写真实 `~/.dsh`。
|
|
34
|
+
- 证据:`gpw-hardening 8/8`、`gpw-folder-policy 9/9`、`gpw-activation 4/4`、`client-bundle 26/26`(node 进程内运行)。
|
|
35
|
+
|
|
36
|
+
## [1.7.8] — 2026-10-26
|
|
37
|
+
|
|
38
|
+
**GPW 启用失败修复(第二个):`TypeError: Cannot read properties of undefined (reading 'layers')`。**
|
|
39
|
+
|
|
40
|
+
- 根因:v1.7.7 为做黑名单同步拒绝,在**激活期**调用根上下文的 `ctx.tools.guard()`。tools 服务是**按 agent 组合**的,
|
|
41
|
+
根作用域实例在激活阶段还没有 layer 表,于是抛 `reading 'layers'`,dsh-gpw 仍无法激活。
|
|
42
|
+
- 修复:改为在 `agent/created` 监听里对**每个 agent 自己的上下文** `agent.ctx.tools.guard(...)` 注册黑名单守卫,
|
|
43
|
+
并用 `ctx.effect` 统一释放;根上下文不再读取 tools 服务(`inject` 回到只有 `permissionPresets`)。
|
|
44
|
+
- 保留 `tools/pre-execute` 三分类拦截;guard 缺失的宿主不会因此激活失败(pre-execute 仍会拒绝黑名单)。
|
|
45
|
+
- 回归测试升级:`test/gpw-activation.test.mjs` 现在用严格 Cordis 语义 Proxy(读取根 `tools` 即抛错)验证激活,
|
|
46
|
+
并通过伪造 `agent/created` 断言守卫注册在 agent 上下文。
|
|
47
|
+
- 同时补全 bundle patch 中 GPW v2 的配置项(requireFullAccess / whitelist / blacklist / pendingTimeoutMs)。
|
|
48
|
+
- 证据:`docs/acceptance/1.7.8/GPW-AGENT-SCOPE.json`。
|
|
10
49
|
## [1.7.7] — 2026-10-26
|
|
11
50
|
|
|
12
51
|
**GPW 子插件启用失败修复:`cannot get property "tools" without inject`。**
|
package/codex/mcp-servers.toml
CHANGED
package/cordis.patch.yml
CHANGED
|
@@ -134,4 +134,10 @@
|
|
|
134
134
|
maxPerWindow: 10
|
|
135
135
|
windowMs: 60000
|
|
136
136
|
minGapMs: 3000
|
|
137
|
+
# v1.7.5+: only act in the 完全权限 preset; the two folder lists are the gate's policy.
|
|
138
|
+
# Folders are added at runtime from the GPW panel into $DSH_HOME/xclient/gpw-folders.json.
|
|
139
|
+
requireFullAccess: true
|
|
140
|
+
whitelist: []
|
|
141
|
+
blacklist: []
|
|
142
|
+
pendingTimeoutMs: 120000
|
|
137
143
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.8",
|
|
3
|
+
"case": "GPW-AGENT-SCOPE",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-25T16:57:54.149Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "local-gpw-scope-20261025165754",
|
|
11
|
+
"requirement": "enabling dsh-gpw must activate it under a full CORDIS context: no root tools read, the pre-execute gate installed, and the blacklist guard registered on each agent context",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"symptom": "TypeError: Cannot read properties of undefined (reading 'layers')",
|
|
16
|
+
"cause": "ctx.tools.guard was called at activation on the root context; the tools service is composed per agent and has no layer table there",
|
|
17
|
+
"fix": "agent/created -> agent.ctx.tools.guard per agent, disposers released through ctx.effect; inject = [permissionPresets]",
|
|
18
|
+
"regression": "test/gpw-activation.test.mjs: strict Proxy throws on undeclared/root-tools reads; activation, agent/created guard installation and source checks all pass"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.7.9",
|
|
3
|
+
"case": "GPW-HARDENING",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-25T23:26:10.976Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "local-gpw-hardening-20261025232610",
|
|
11
|
+
"requirement": "a pending class-C call is visible and answerable inline, the three folder lists collapse after three rows, and the folder lists file is MAC-signed and read-only so an external edit is detected and fails closed until the operator resets",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"inline_prompt": "GpwPrompt is mounted on conversation.input.left (id w2m-gpw-prompt): pending count + tool name + allow/deny/open-panel; the launcher shows a badge too",
|
|
16
|
+
"collapsed_lists": "whitelist / blacklist / recently intercepted each show 3 rows, then \"另有 N 个,展开/收起\" with per-list state",
|
|
17
|
+
"signing": "v2 HMAC-SHA256 covers both lists; key from foldersKey config / W2M_GPW_FOLDERS_KEY / sidecar *.key (0600); atomic tmp->rename write then 0444 read-only",
|
|
18
|
+
"tamper_fail_closed": "missing / unreadable / bad-MAC / unsigned-after-seen -> tampered: every path-bearing call is denied (409 in clients) until POST /api/w2m/gpw/reset re-signs",
|
|
19
|
+
"legacy_migration": "an unsigned pre-1.7.9 file is migrated once, then treated as tampered if it reappears",
|
|
20
|
+
"tests": "gpw-hardening 8/8; gpw-tamper-gate 4/4; gpw-persistence 5/5; gpw-activation 4/4; gpw-folder-policy 12/12 (recursive + relative cases included); client-bundle 26/26; claims/installed-version/acceptance-doc pass",
|
|
21
|
+
"pack": "scripts/pack.mjs builds the tarball deterministically; the canonical sha256 is published on the GitHub Release asset and verified equal to the npm tarball after release",
|
|
22
|
+
"list_inheritance": "lists and MAC key live under $DSH_HOME/xclient (outside the plugin package): upgrades, restarts and uninstall/reinstall reuse the same signed file; only explicit panel actions (remove row / reset) change the lists — covered by test/gpw-persistence.test.mjs",
|
|
23
|
+
"recursive_folder_lists": "a listed folder is a root covering every subfolder (E:\\work matches E:\\work\\a\\b\\c.txt; E:\\workshop does not match); a nested blacklist root still wins; relative command paths are resolved against the session workspace before classification — covered by gpw-folder-policy tests"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -155,6 +155,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
155
155
|
gpwPendingEmpty: '当前没有待审批调用',
|
|
156
156
|
gpwAllowOnce: '允许本次',
|
|
157
157
|
gpwDenyOnce: '拒绝本次',
|
|
158
|
+
gpwMore: '另有 {count} 个,展开',
|
|
159
|
+
gpwFold: '收起',
|
|
160
|
+
gpwAddWhiteAllow: '加入白名单并允许',
|
|
161
|
+
gpwAddBlackDeny: '加入黑名单并拒绝',
|
|
162
|
+
gpwPendingShort: 'GPW 待审批:{tool}',
|
|
163
|
+
gpwPendingCount: '有 {count} 个调用等待审批',
|
|
164
|
+
gpwOpenPanel: '打开面板',
|
|
165
|
+
gpwTampered: '名单完整性校验失败({reason}),名单外路径已锁定。',
|
|
166
|
+
gpwReset: '重置名单(清空并重新签名)',
|
|
167
|
+
gpwResetConfirm: '确认重置?将清空白名单和黑名单的全部条目。',
|
|
158
168
|
},
|
|
159
169
|
en: {
|
|
160
170
|
all: 'All machines',
|
|
@@ -207,6 +217,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
207
217
|
gpwPendingEmpty: 'No calls are waiting for approval',
|
|
208
218
|
gpwAllowOnce: 'Allow this call',
|
|
209
219
|
gpwDenyOnce: 'Deny this call',
|
|
220
|
+
gpwMore: 'Show {count} more',
|
|
221
|
+
gpwFold: 'Show less',
|
|
222
|
+
gpwAddWhiteAllow: 'Always allow (add to whitelist)',
|
|
223
|
+
gpwAddBlackDeny: 'Always deny (add to blacklist)',
|
|
224
|
+
gpwPendingShort: 'GPW pending: {tool}',
|
|
225
|
+
gpwPendingCount: '{count} call(s) waiting for approval',
|
|
226
|
+
gpwOpenPanel: 'Open panel',
|
|
227
|
+
gpwTampered: 'Folder lists failed integrity check ({reason}); non-listed paths are locked.',
|
|
228
|
+
gpwReset: 'Reset lists (clear + re-sign)',
|
|
229
|
+
gpwResetConfirm: 'Reset the lists? Every whitelist and blacklist entry will be cleared.',
|
|
210
230
|
},
|
|
211
231
|
};
|
|
212
232
|
|
|
@@ -407,6 +427,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
407
427
|
'.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
|
|
408
428
|
'.w2mGpw_launcher{position:fixed;right:16px;bottom:96px;z-index:55;padding:6px 10px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer}',
|
|
409
429
|
'.w2mGpw_launcher:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
430
|
+
'.w2mGpw_prompt{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
|
|
431
|
+
'.w2mGpw_promptLabel{font-size:12px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-primary))}',
|
|
432
|
+
'.w2mGpw_badge{display:inline-block;min-width:16px;padding:0 5px;margin-left:4px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:14px;text-align:center}',
|
|
410
433
|
'.w2mGpw_overlay{position:fixed;inset:0;z-index:70;background:color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);display:flex;align-items:flex-end;justify-content:flex-end;padding:16px}',
|
|
411
434
|
'.w2mGpw_card{width:420px;max-width:calc(100vw - 32px);max-height:min(80vh,640px);overflow:auto;box-sizing:border-box;padding:10px 12px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
|
|
412
435
|
'.w2mGpw_head{display:flex;align-items:center;justify-content:space-between}',
|
|
@@ -545,6 +568,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
545
568
|
const [busy, setBusy] = React.useState(false);
|
|
546
569
|
const [manual, setManual] = React.useState('');
|
|
547
570
|
const [badPath, setBadPath] = React.useState(null);
|
|
571
|
+
const [expanded, setExpanded] = React.useState({});
|
|
548
572
|
const call = React.useCallback(async (url, init) => {
|
|
549
573
|
try {
|
|
550
574
|
const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
|
|
@@ -588,13 +612,37 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
588
612
|
setBusy(true);
|
|
589
613
|
try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
|
|
590
614
|
};
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
615
|
+
/** v1.7.9: after a tamper report the signed lists are locked until the operator resets them. */
|
|
616
|
+
const resetLists = async () => {
|
|
617
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function' && window.confirm(t('gpwResetConfirm')) !== true) return;
|
|
618
|
+
setBusy(true);
|
|
619
|
+
try { await call('/api/w2m/gpw/reset', { method: 'POST', body: JSON.stringify({ confirm: 'reset' }) }); await load(); } finally { setBusy(false); }
|
|
620
|
+
};
|
|
621
|
+
/** v1.7.9: lists show three rows by default; the rest fold behind one toggle. */
|
|
622
|
+
const foldItems = (items, key) => (expanded[key] === true ? items : items.slice(0, 3));
|
|
623
|
+
const foldRow = (items, key) => (items.length <= 3 ? null : h('button', {
|
|
624
|
+
key: 'fold-' + key,
|
|
625
|
+
type: 'button',
|
|
626
|
+
className: 'w2mGpw_btn',
|
|
627
|
+
disabled: busy,
|
|
628
|
+
onClick: () => setExpanded((prev) => ({ ...prev, [key]: prev[key] !== true })),
|
|
629
|
+
}, expanded[key] === true ? t('gpwFold') : t('gpwMore', { count: items.length - 3 })));
|
|
630
|
+
const allowWithFolder = async (item, list) => {
|
|
631
|
+
if (typeof item?.folder !== 'string' || item.folder === '') return;
|
|
632
|
+
const added = await addFolder(list, item.folder);
|
|
633
|
+
if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
|
|
634
|
+
};
|
|
635
|
+
const folderList = (title, list) => {
|
|
636
|
+
const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
|
|
637
|
+
return h('div', { key: list, className: 'w2mGpw_block' }, [
|
|
638
|
+
h('div', { key: 'title', className: 'w2mGpw_title' }, title),
|
|
639
|
+
...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
|
|
640
|
+
h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
|
|
641
|
+
h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
|
|
642
|
+
])),
|
|
643
|
+
foldRow(items, list),
|
|
644
|
+
]);
|
|
645
|
+
};
|
|
598
646
|
const recent = Array.isArray(state?.recent) ? state.recent : [];
|
|
599
647
|
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
600
648
|
return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
|
|
@@ -604,6 +652,12 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
604
652
|
h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
|
|
605
653
|
]),
|
|
606
654
|
error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
|
|
655
|
+
state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
|
|
656
|
+
h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
|
|
657
|
+
h('div', { key: 'a', className: 'w2mGpw_actions' }, [
|
|
658
|
+
h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
|
|
659
|
+
]),
|
|
660
|
+
]) : null,
|
|
607
661
|
h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
|
|
608
662
|
h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
|
|
609
663
|
h('input', {
|
|
@@ -636,6 +690,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
636
690
|
h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
|
|
637
691
|
]),
|
|
638
692
|
])),
|
|
693
|
+
foldRow(recent, 'recent'),
|
|
639
694
|
]),
|
|
640
695
|
pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
|
|
641
696
|
h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
|
|
@@ -645,6 +700,8 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
645
700
|
h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
|
|
646
701
|
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
|
|
647
702
|
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
|
|
703
|
+
item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
|
|
704
|
+
item.folder === null || item.folder === undefined ? null : h('button', { key: 'ab', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'blacklist') }, t('gpwAddBlackDeny')),
|
|
648
705
|
]),
|
|
649
706
|
])),
|
|
650
707
|
]),
|
|
@@ -652,22 +709,107 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
|
|
|
652
709
|
]);
|
|
653
710
|
}
|
|
654
711
|
|
|
655
|
-
/** The floating launcher: the panel
|
|
712
|
+
/** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
|
|
656
713
|
function GpwLauncher({ t }) {
|
|
657
714
|
const [open, setOpen] = React.useState(false);
|
|
715
|
+
const [pendingCount, setPendingCount] = React.useState(0);
|
|
716
|
+
React.useEffect(() => {
|
|
717
|
+
let alive = true;
|
|
718
|
+
const load = async () => {
|
|
719
|
+
try {
|
|
720
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
721
|
+
const body = response.ok ? await response.json() : null;
|
|
722
|
+
if (alive) setPendingCount(body?.ok === true && Array.isArray(body.pending) ? body.pending.length : 0);
|
|
723
|
+
} catch {
|
|
724
|
+
if (alive) setPendingCount(0);
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
load();
|
|
728
|
+
const timer = setInterval(load, 3000);
|
|
729
|
+
const onOpen = () => setOpen(true);
|
|
730
|
+
window.addEventListener('w2m-gpw-open', onOpen);
|
|
731
|
+
return () => {
|
|
732
|
+
alive = false;
|
|
733
|
+
clearInterval(timer);
|
|
734
|
+
window.removeEventListener('w2m-gpw-open', onOpen);
|
|
735
|
+
};
|
|
736
|
+
}, []);
|
|
658
737
|
return h(React.Fragment, null, [
|
|
659
738
|
h('button', {
|
|
660
739
|
key: 'launcher',
|
|
661
740
|
type: 'button',
|
|
662
741
|
className: 'w2mGpw_launcher',
|
|
663
|
-
title: t('gpwLauncher'),
|
|
742
|
+
title: pendingCount > 0 ? t('gpwPendingCount', { count: pendingCount }) : t('gpwLauncher'),
|
|
664
743
|
'aria-expanded': open,
|
|
665
744
|
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
666
|
-
},
|
|
745
|
+
}, [
|
|
746
|
+
t('gpwLauncher'),
|
|
747
|
+
pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
|
|
748
|
+
]),
|
|
667
749
|
open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
|
|
668
750
|
]);
|
|
669
751
|
}
|
|
670
752
|
|
|
753
|
+
/**
|
|
754
|
+
* v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
|
|
755
|
+
* (allow once / deny once / open the panel) instead of waiting silently for the operator.
|
|
756
|
+
*/
|
|
757
|
+
function GpwPrompt({ t }) {
|
|
758
|
+
const [state, setState] = React.useState(null);
|
|
759
|
+
const [busy, setBusy] = React.useState(false);
|
|
760
|
+
React.useEffect(() => {
|
|
761
|
+
let alive = true;
|
|
762
|
+
const load = async () => {
|
|
763
|
+
try {
|
|
764
|
+
const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
765
|
+
const body = response.ok ? await response.json() : null;
|
|
766
|
+
if (alive) setState(body);
|
|
767
|
+
} catch {
|
|
768
|
+
if (alive) setState(null);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
load();
|
|
772
|
+
const timer = setInterval(load, 2000);
|
|
773
|
+
return () => {
|
|
774
|
+
alive = false;
|
|
775
|
+
clearInterval(timer);
|
|
776
|
+
};
|
|
777
|
+
}, []);
|
|
778
|
+
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
779
|
+
if (pending.length === 0) return null;
|
|
780
|
+
const item = pending[0];
|
|
781
|
+
const decide = async (decision) => {
|
|
782
|
+
setBusy(true);
|
|
783
|
+
try {
|
|
784
|
+
await fetch('/api/w2m/gpw/decide', {
|
|
785
|
+
method: 'POST',
|
|
786
|
+
credentials: 'same-origin',
|
|
787
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
788
|
+
body: JSON.stringify({ id: item.id, decision }),
|
|
789
|
+
});
|
|
790
|
+
} catch {
|
|
791
|
+
/* the next poll shows the queue again */
|
|
792
|
+
} finally {
|
|
793
|
+
setBusy(false);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
const openPanel = () => {
|
|
797
|
+
try {
|
|
798
|
+
window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
|
|
799
|
+
} catch {
|
|
800
|
+
/* no window in a headless render */
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
|
|
804
|
+
h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
|
|
805
|
+
? t('gpwPendingCount', { count: pending.length })
|
|
806
|
+
: t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
|
|
807
|
+
h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
|
|
808
|
+
h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
|
|
809
|
+
h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
|
|
810
|
+
]);
|
|
811
|
+
}
|
|
812
|
+
|
|
671
813
|
/** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
|
|
672
814
|
function PluginUpdate({ t }) {
|
|
673
815
|
const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
|
|
@@ -1543,6 +1685,19 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1543
1685
|
} catch (error) {
|
|
1544
1686
|
console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
|
|
1545
1687
|
}
|
|
1688
|
+
// v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
|
|
1689
|
+
// asks allow/deny immediately instead of waiting silently in the panel.
|
|
1690
|
+
try {
|
|
1691
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
1692
|
+
name: 'conversation.input.left',
|
|
1693
|
+
id: 'w2m-gpw-prompt',
|
|
1694
|
+
order: 44,
|
|
1695
|
+
label: 'GPW pending prompt',
|
|
1696
|
+
inject: () => ({ t }),
|
|
1697
|
+
}, GpwPrompt));
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
|
|
1700
|
+
}
|
|
1546
1701
|
try {
|
|
1547
1702
|
slots.inject('conversation.input.left', () => slots.register({
|
|
1548
1703
|
name: 'conversation.input.left',
|
|
@@ -1601,6 +1756,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
1601
1756
|
exports.inject = inject;
|
|
1602
1757
|
exports.TargetSelect = TargetSelect;
|
|
1603
1758
|
exports.GpwLauncher = GpwLauncher;
|
|
1759
|
+
exports.GpwPrompt = GpwPrompt;
|
|
1604
1760
|
exports.FleetChat = FleetChat;
|
|
1605
1761
|
exports.injectStyle = injectStyle;
|
|
1606
1762
|
exports.makeTranslator = makeTranslator;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.9",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
|
|
83
83
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
84
84
|
"test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
|
|
85
|
-
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs",
|
|
85
|
+
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
|
|
86
86
|
"test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
|
|
87
87
|
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
|
88
88
|
"test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.9",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -107,7 +107,7 @@ const COMMAND_TOOL = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i
|
|
|
107
107
|
* @param {{name?: string, arguments?: string|object}} call
|
|
108
108
|
* @returns {string[]} raw (not yet normalised) path candidates
|
|
109
109
|
*/
|
|
110
|
-
export function pathsOfToolCall(call) {
|
|
110
|
+
export function pathsOfToolCall(call, options = {}) {
|
|
111
111
|
let args = call?.arguments;
|
|
112
112
|
if (typeof args === 'string') {
|
|
113
113
|
if (args.trim() === '') return [];
|
|
@@ -137,8 +137,20 @@ export function pathsOfToolCall(call) {
|
|
|
137
137
|
: typeof call?.arguments === 'string'
|
|
138
138
|
? call.arguments
|
|
139
139
|
: JSON.stringify(args ?? '');
|
|
140
|
-
const matches = text.match(/(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
|
|
140
|
+
const matches = text.match(/(?<![^\s"'=(])(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
|
|
141
141
|
for (const m of matches) push(m);
|
|
142
|
+
// v1.7.9: relative tokens (sub/file.txt, ./x, ..\y) are resolved against the workspace base below, so a command
|
|
143
|
+
// that names a subfolder is classified instead of passing through as "names no path". Only tokens that actually
|
|
144
|
+
// contain a separator are taken; URLs, flags and bare words are left alone.
|
|
145
|
+
if (options.allowRelative === true) {
|
|
146
|
+
for (const token of text.split(/\s+/)) {
|
|
147
|
+
const cleaned = token.replace(/^["'(=]+|["'),;]+$/g, '');
|
|
148
|
+
if (cleaned === '' || cleaned.startsWith('-') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(cleaned)) continue;
|
|
149
|
+
if (!/[\\/]/.test(cleaned)) continue;
|
|
150
|
+
if (/[\\/]$/.test(cleaned) && cleaned.length === 1) continue;
|
|
151
|
+
push(cleaned);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
142
154
|
}
|
|
143
155
|
return out;
|
|
144
156
|
}
|
|
@@ -149,11 +161,11 @@ export function pathsOfToolCall(call) {
|
|
|
149
161
|
* the ordinary one.
|
|
150
162
|
*
|
|
151
163
|
* @param {{name?: string, arguments?: string|object, whitelist?: string[], blacklist?: string[],
|
|
152
|
-
* workspace?: string|null, base?: string, platform?: string}} call
|
|
164
|
+
* workspace?: string|null, base?: string, platform?: string, allowRelative?: boolean}} call
|
|
153
165
|
* @returns {{decision: 'deny'|'allow'|'pass', path: string|null, folder: string|null, reason: string, paths: string[]}}
|
|
154
166
|
*/
|
|
155
167
|
export function classifyToolCall(call) {
|
|
156
|
-
const candidates = pathsOfToolCall(call);
|
|
168
|
+
const candidates = pathsOfToolCall(call, { allowRelative: call.allowRelative === true });
|
|
157
169
|
let weakest = { decision: 'pass', path: null, folder: null, reason: 'this call names no path', paths: [] };
|
|
158
170
|
for (const candidate of candidates) {
|
|
159
171
|
const result = classifyPath({
|
package/src/plugin/gpw.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ## The three classes and who answers
|
|
5
5
|
*
|
|
6
|
-
* A. blacklist -> denied. A synchronous
|
|
6
|
+
* A. blacklist -> denied. A synchronous guard is registered on each agent (agent.ctx.tools.guard), so the denial holds no matter
|
|
7
7
|
* which other pre-execute listener runs first.
|
|
8
8
|
* B. whitelist -> allowed without a human. The whitelist is an explicit extension of the workspace (same decision,
|
|
9
9
|
* plus the auto-approval budget below).
|
|
@@ -25,35 +25,41 @@
|
|
|
25
25
|
*
|
|
26
26
|
* ## Operator surface
|
|
27
27
|
*
|
|
28
|
-
* The panel is rendered by the w2m client (`lib/client.js`): a
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* The panel is rendered by the w2m client (`lib/client.js`): a pending prompt sits inline in the composer row,
|
|
29
|
+
* and a floating "GPW 文件夹" button opens the three sections (whitelist / blacklist / recently intercepted)
|
|
30
|
+
* plus the pending queue. Folders can be typed (validated against the filesystem here) or taken from the
|
|
31
|
+
* recently-intercepted list in one click. Lists live in a MAC-signed v2 JSON file under `$DSH_HOME/xclient/`
|
|
32
|
+
* (written read-only 0444), never in the profile patch; an external edit is detected and locks class-B/C paths
|
|
33
|
+
* until the operator resets the lists.
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
36
|
import { spawn } from 'node:child_process';
|
|
35
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
36
38
|
import { homedir } from 'node:os';
|
|
37
39
|
import { dirname, join, resolve } from 'node:path';
|
|
38
|
-
import { randomUUID } from 'node:crypto';
|
|
40
|
+
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
39
41
|
|
|
40
42
|
import { classifyToolCall } from './folder-policy.mjs';
|
|
41
43
|
|
|
42
44
|
export const name = 'dsh-gpw';
|
|
43
45
|
|
|
44
46
|
/** Services this plugin cannot work without. `connection` is used only for the operator routes. */
|
|
45
|
-
export const inject = ['permissionPresets'
|
|
47
|
+
export const inject = ['permissionPresets'];
|
|
46
48
|
|
|
47
49
|
export const FULL_ACCESS_PRESET = 'danger-full-access';
|
|
48
50
|
export const GPW_ROUTE = '/api/w2m/gpw/state';
|
|
49
51
|
export const GPW_FOLDERS_ROUTE = '/api/w2m/gpw/folders';
|
|
50
52
|
export const GPW_DECIDE_ROUTE = '/api/w2m/gpw/decide';
|
|
51
53
|
export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
|
|
54
|
+
export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
|
|
52
55
|
|
|
53
56
|
const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
|
|
54
57
|
const DEFAULT_PENDING_TIMEOUT_MS = 120_000;
|
|
55
58
|
const RECENT_LIMIT = 20;
|
|
56
59
|
const NO_STORE = { 'cache-control': 'no-store' };
|
|
60
|
+
/** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
|
|
61
|
+
const FOLDERS_VERSION = 2;
|
|
62
|
+
const FOLDERS_MAC_ALGO = 'sha256';
|
|
57
63
|
|
|
58
64
|
/** Resolve the folders file: config first, then `$DSH_HOME/xclient/gpw-folders.json`. */
|
|
59
65
|
export function foldersFileOf(config = {}) {
|
|
@@ -62,28 +68,116 @@ export function foldersFileOf(config = {}) {
|
|
|
62
68
|
return join(home, 'xclient', 'gpw-folders.json');
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
71
|
+
/** The sidecar file that holds the MAC key. Kept next to (never inside) the lists file. */
|
|
72
|
+
export function foldersKeyFileOf(file) {
|
|
73
|
+
return `${file}.key`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the MAC key: explicit config / env first, else a generated 32-byte key in a 0600 sidecar.
|
|
78
|
+
* `created` is true only when this call generated the key, which is what lets a legacy plain-JSON
|
|
79
|
+
* file be migrated exactly once and a later unsigned file be treated as tampering instead.
|
|
80
|
+
*/
|
|
81
|
+
export function ensureFoldersKey(file, config = {}) {
|
|
82
|
+
const fromConfig = typeof config.foldersKey === 'string' && config.foldersKey.trim() !== '' ? config.foldersKey.trim() : null;
|
|
83
|
+
const fromEnv = typeof process.env.W2M_GPW_FOLDERS_KEY === 'string' && process.env.W2M_GPW_FOLDERS_KEY.trim() !== ''
|
|
84
|
+
? process.env.W2M_GPW_FOLDERS_KEY.trim()
|
|
85
|
+
: null;
|
|
86
|
+
const explicit = fromConfig ?? fromEnv;
|
|
87
|
+
if (explicit !== null) return { key: explicit, created: false, source: fromConfig !== null ? 'config' : 'env' };
|
|
88
|
+
const keyFile = foldersKeyFileOf(file);
|
|
67
89
|
try {
|
|
68
|
-
const
|
|
69
|
-
return {
|
|
70
|
-
whitelist: Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [],
|
|
71
|
-
blacklist: Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [],
|
|
72
|
-
};
|
|
90
|
+
const existing = readFileSync(keyFile, 'utf8').trim();
|
|
91
|
+
if (existing !== '') return { key: existing, created: false, source: 'file' };
|
|
73
92
|
} catch {
|
|
74
|
-
|
|
93
|
+
/* generated below */
|
|
75
94
|
}
|
|
95
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
96
|
+
const key = randomBytes(32).toString('hex');
|
|
97
|
+
writeFileSync(keyFile, `${key}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
98
|
+
try { chmodSync(keyFile, 0o600); } catch { /* best effort on Windows */ }
|
|
99
|
+
return { key, created: true, source: 'file' };
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
/**
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
/** Canonical MAC over the two lists (order-independent, so adding/removing a row is what changes it). */
|
|
103
|
+
export function macOfFolders(whitelist, blacklist, key) {
|
|
104
|
+
const canonical = JSON.stringify({
|
|
105
|
+
whitelist: [...new Set(whitelist)].sort(),
|
|
106
|
+
blacklist: [...new Set(blacklist)].sort(),
|
|
107
|
+
});
|
|
108
|
+
return createHmac(FOLDERS_MAC_ALGO, key).update(canonical).digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the operator-managed lists.
|
|
113
|
+
*
|
|
114
|
+
* - missing file + `allowMissing` -> clean empty lists (first run)
|
|
115
|
+
* - missing file otherwise -> `tampered` (the signed file was deleted)
|
|
116
|
+
* - valid v2 + MAC -> lists
|
|
117
|
+
* - v2 with a bad MAC -> `tampered` (edited outside GPW)
|
|
118
|
+
* - no version/MAC (legacy) -> `legacy: true`; the caller migrates once or treats it as tampered
|
|
119
|
+
*/
|
|
120
|
+
export function readFolders(file, key = null, options = {}) {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(file, 'utf8');
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
|
|
126
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
|
|
127
|
+
}
|
|
128
|
+
if (error?.code !== 'ENOENT') {
|
|
129
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
|
|
130
|
+
}
|
|
131
|
+
return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
|
|
132
|
+
}
|
|
133
|
+
let parsed = null;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
|
|
138
|
+
}
|
|
139
|
+
const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
140
|
+
const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
|
|
141
|
+
if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
|
|
142
|
+
return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
|
|
143
|
+
}
|
|
144
|
+
if (key === null) return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
|
|
145
|
+
if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
|
|
146
|
+
return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
|
|
147
|
+
}
|
|
148
|
+
return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Persist the operator-managed lists.
|
|
153
|
+
*
|
|
154
|
+
* Atomic (tmp + rename), MAC-signed when a key is supplied, and left **read-only** (0444) so an
|
|
155
|
+
* accidental or casual edit from the agent's file tools fails instead of silently changing policy.
|
|
156
|
+
* GPW itself rewrites by clearing the flag, writing a new tmp file and renaming over the target.
|
|
157
|
+
*/
|
|
158
|
+
export function writeFolders(file, folders, key = null) {
|
|
159
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
160
|
+
const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
161
|
+
const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
|
|
162
|
+
const next = { version: FOLDERS_VERSION, whitelist, blacklist };
|
|
163
|
+
if (key !== null) {
|
|
164
|
+
next.algo = FOLDERS_MAC_ALGO;
|
|
165
|
+
next.mac = macOfFolders(whitelist, blacklist, key);
|
|
166
|
+
}
|
|
167
|
+
const payload = `${JSON.stringify(next, null, 2)}\n`;
|
|
168
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
169
|
+
// Windows refuses to rename over a read-only target: clear it first, then the atomic replace runs.
|
|
170
|
+
try { chmodSync(file, 0o644); } catch { /* first write */ }
|
|
171
|
+
writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
172
|
+
try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
|
|
173
|
+
renameSync(tmp, file);
|
|
174
|
+
try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
|
|
175
|
+
return { whitelist, blacklist };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
|
|
179
|
+
export function resetFolders(file, key) {
|
|
180
|
+
return writeFolders(file, { whitelist: [], blacklist: [] }, key);
|
|
87
181
|
}
|
|
88
182
|
|
|
89
183
|
/**
|
|
@@ -229,6 +323,33 @@ export function apply(ctx, config = {}) {
|
|
|
229
323
|
const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
|
|
230
324
|
const requireFullAccess = config.requireFullAccess !== false;
|
|
231
325
|
const foldersFile = foldersFileOf(config);
|
|
326
|
+
const keyInfo = ensureFoldersKey(foldersFile, config);
|
|
327
|
+
const foldersKey = keyInfo.key;
|
|
328
|
+
/** Set once the signed file has been read cleanly; a later disappearance then reads as tampering. */
|
|
329
|
+
let foldersSeen = false;
|
|
330
|
+
/** Non-null until the operator resets the lists; class-B/C paths are locked while it is set. */
|
|
331
|
+
let tamperedReason = null;
|
|
332
|
+
|
|
333
|
+
const noteTamper = (reason) => {
|
|
334
|
+
const next = reason ?? 'folder lists failed integrity check';
|
|
335
|
+
if (tamperedReason === null) ctx.logger?.warn?.(`dsh-gpw: ${next}; class-B/C paths are locked until the operator resets the lists`);
|
|
336
|
+
tamperedReason = next;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// First read: migrate a legacy plain-JSON file exactly once; after that an unsigned/missing file is tampering.
|
|
340
|
+
const initialFolders = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created || !existsSync(foldersFile) });
|
|
341
|
+
if (initialFolders.legacy) ctx.logger?.info?.('dsh-gpw: migrating legacy folder lists to MAC-signed v2 (read-only)');
|
|
342
|
+
if (initialFolders.tampered) {
|
|
343
|
+
noteTamper(initialFolders.reason);
|
|
344
|
+
} else {
|
|
345
|
+
// Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
|
|
346
|
+
try {
|
|
347
|
+
writeFolders(foldersFile, initialFolders, foldersKey);
|
|
348
|
+
foldersSeen = true;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
noteTamper(`folder lists could not be initialized: ${error?.message ?? error}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
232
353
|
|
|
233
354
|
/** Timestamps of auto-approvals in the current window. */
|
|
234
355
|
const approvals = [];
|
|
@@ -247,11 +368,20 @@ export function apply(ctx, config = {}) {
|
|
|
247
368
|
};
|
|
248
369
|
|
|
249
370
|
const folderLists = () => {
|
|
250
|
-
const stored = readFolders(foldersFile);
|
|
371
|
+
const stored = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created && !foldersSeen });
|
|
372
|
+
if (stored.legacy) {
|
|
373
|
+
noteTamper('folder lists lost their integrity signature (edited outside GPW?)');
|
|
374
|
+
} else if (stored.tampered) {
|
|
375
|
+
noteTamper(stored.reason);
|
|
376
|
+
} else {
|
|
377
|
+
foldersSeen = true;
|
|
378
|
+
}
|
|
251
379
|
const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
|
|
252
380
|
return {
|
|
253
381
|
whitelist: merge(config.whitelist, stored.whitelist),
|
|
254
382
|
blacklist: merge(config.blacklist, stored.blacklist),
|
|
383
|
+
tampered: tamperedReason !== null,
|
|
384
|
+
reason: tamperedReason,
|
|
255
385
|
};
|
|
256
386
|
};
|
|
257
387
|
|
|
@@ -322,38 +452,85 @@ export function apply(ctx, config = {}) {
|
|
|
322
452
|
};
|
|
323
453
|
|
|
324
454
|
// --- A: the blacklist denial must hold regardless of listener order -------------------------------------------------
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
455
|
+
//
|
|
456
|
+
// v1.7.8: the guard is registered on each agent's own context, from an `agent/created` listener.
|
|
457
|
+
//
|
|
458
|
+
// Registering it on the root context (ctx.tools.guard at activation) crashed dsh-gpw with
|
|
459
|
+
// TypeError: Cannot read properties of undefined (reading 'layers')
|
|
460
|
+
// because the tools service is composed per agent: the root-scope instance has no layer table yet at activation time.
|
|
461
|
+
// The documented shape for per-agent behavior is agent.ctx obtained in agent/created; the disposer is kept so an
|
|
462
|
+
// unloaded plugin cannot leave guards behind.
|
|
463
|
+
/** @type {Map<object, () => void>} */
|
|
464
|
+
const agentGuards = new Map();
|
|
465
|
+
const blacklistGuard = () => (exec) => {
|
|
332
466
|
if (!isFullAccess(exec?.agent)) return undefined;
|
|
333
467
|
const lists = folderLists();
|
|
334
|
-
if (lists.blacklist.length === 0) return undefined;
|
|
335
468
|
const call = toolCallOf(exec);
|
|
469
|
+
if (lists.tampered) {
|
|
470
|
+
// Fail closed: while the lists cannot be trusted, every path-naming call is refused outright.
|
|
471
|
+
const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
|
|
472
|
+
if (probe.paths.length === 0) return undefined;
|
|
473
|
+
deniedTotal += 1;
|
|
474
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
|
|
475
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
476
|
+
return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
|
|
477
|
+
}
|
|
478
|
+
if (lists.blacklist.length === 0) return undefined;
|
|
336
479
|
const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
|
|
337
480
|
if (result.decision !== 'deny') return undefined;
|
|
338
481
|
deniedTotal += 1;
|
|
339
482
|
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
|
|
340
483
|
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${result.reason}`);
|
|
341
484
|
return `GPW: ${result.reason}; the call was not executed`;
|
|
485
|
+
};
|
|
486
|
+
ctx.on('agent/created', ({ agent } = {}) => {
|
|
487
|
+
try {
|
|
488
|
+
const guard = agent?.ctx?.tools?.guard;
|
|
489
|
+
if (typeof guard !== 'function') return; // host without the guard extension: pre-execute still denies
|
|
490
|
+
const dispose = guard(blacklistGuard());
|
|
491
|
+
if (typeof dispose === 'function') agentGuards.set(agent, dispose);
|
|
492
|
+
} catch {
|
|
493
|
+
/* the pre-execute branch below still enforces the blacklist */
|
|
494
|
+
}
|
|
342
495
|
});
|
|
496
|
+
if (typeof ctx.effect === 'function') {
|
|
497
|
+
ctx.effect(() => () => {
|
|
498
|
+
for (const dispose of agentGuards.values()) {
|
|
499
|
+
try {
|
|
500
|
+
dispose();
|
|
501
|
+
} catch {
|
|
502
|
+
/* already disposed with its agent */
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
agentGuards.clear();
|
|
506
|
+
}, 'dsh-gpw: per-agent blacklist guards');
|
|
507
|
+
}
|
|
508
|
+
|
|
343
509
|
|
|
344
510
|
// --- B/C: the pre-execute gate ---------------------------------------------------------------------------------------
|
|
345
511
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
346
512
|
if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
|
|
347
513
|
const lists = folderLists();
|
|
348
|
-
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
349
514
|
const call = toolCallOf(exec);
|
|
515
|
+
if (lists.tampered) {
|
|
516
|
+
// Fail closed: a tampered list file must not be able to silently turn policy off.
|
|
517
|
+
const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
|
|
518
|
+
if (probe.paths.length === 0) return next();
|
|
519
|
+
deniedTotal += 1;
|
|
520
|
+
noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
|
|
521
|
+
ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
|
|
522
|
+
return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
|
|
523
|
+
}
|
|
524
|
+
if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
|
|
350
525
|
let cwd = null;
|
|
351
526
|
try {
|
|
352
527
|
cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
|
|
353
528
|
} catch {
|
|
354
529
|
cwd = null;
|
|
355
530
|
}
|
|
356
|
-
|
|
531
|
+
// A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
|
|
532
|
+
// subfolder reference is classified rather than treated as "names no path".
|
|
533
|
+
const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
|
|
357
534
|
if (classification.decision === 'pass' && classification.paths.length === 0) return next();
|
|
358
535
|
const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
|
|
359
536
|
const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
|
|
@@ -383,28 +560,34 @@ export function apply(ctx, config = {}) {
|
|
|
383
560
|
path: GPW_ROUTE,
|
|
384
561
|
methods: ['GET'],
|
|
385
562
|
requestBody: 'buffered',
|
|
386
|
-
fetch: async () =>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
563
|
+
fetch: async () => {
|
|
564
|
+
const lists = folderLists();
|
|
565
|
+
return json({
|
|
566
|
+
ok: true,
|
|
567
|
+
enabled: true,
|
|
568
|
+
require_full_access: requireFullAccess,
|
|
569
|
+
folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
|
|
570
|
+
tampered: lists.tampered,
|
|
571
|
+
tampered_reason: lists.reason,
|
|
572
|
+
counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
|
|
573
|
+
recent: recent.map((entry) => ({
|
|
574
|
+
at: entry.at,
|
|
575
|
+
tool: entry.tool,
|
|
576
|
+
decision: entry.decision,
|
|
577
|
+
reason: entry.reason,
|
|
578
|
+
paths: entry.paths,
|
|
579
|
+
folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
|
|
580
|
+
})),
|
|
581
|
+
pending: [...pending.entries()].map(([id, entry]) => ({
|
|
582
|
+
id,
|
|
583
|
+
tool: entry.tool,
|
|
584
|
+
reason: entry.reason,
|
|
585
|
+
paths: entry.paths,
|
|
586
|
+
folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
|
|
587
|
+
pending_ms: Date.now() - entry.createdAt,
|
|
588
|
+
})),
|
|
589
|
+
});
|
|
590
|
+
},
|
|
408
591
|
});
|
|
409
592
|
|
|
410
593
|
registry.register({
|
|
@@ -421,23 +604,58 @@ export function apply(ctx, config = {}) {
|
|
|
421
604
|
const list = body?.list;
|
|
422
605
|
if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
|
|
423
606
|
if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
|
|
607
|
+
const current = folderLists();
|
|
608
|
+
if (current.tampered) {
|
|
609
|
+
return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
|
|
610
|
+
}
|
|
611
|
+
const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
|
|
424
612
|
if (body.action === 'remove') {
|
|
425
613
|
if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
|
|
426
|
-
const stored = readFolders(foldersFile);
|
|
427
614
|
const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
|
|
428
|
-
|
|
615
|
+
try {
|
|
616
|
+
writeFolders(foldersFile, next, foldersKey);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
|
|
619
|
+
}
|
|
429
620
|
return json({ ok: true, folders: folderLists() });
|
|
430
621
|
}
|
|
431
622
|
const validated = validateFolderInput(body?.path);
|
|
432
623
|
if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
|
|
433
|
-
const stored = readFolders(foldersFile);
|
|
434
624
|
const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
|
|
435
|
-
|
|
625
|
+
try {
|
|
626
|
+
writeFolders(foldersFile, next, foldersKey);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
|
|
629
|
+
}
|
|
436
630
|
ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
|
|
437
631
|
return json({ ok: true, folders: folderLists() });
|
|
438
632
|
},
|
|
439
633
|
});
|
|
440
634
|
|
|
635
|
+
registry.register({
|
|
636
|
+
path: GPW_RESET_ROUTE,
|
|
637
|
+
methods: ['POST'],
|
|
638
|
+
requestBody: 'buffered',
|
|
639
|
+
fetch: async (request) => {
|
|
640
|
+
let body = null;
|
|
641
|
+
try {
|
|
642
|
+
body = await request.json();
|
|
643
|
+
} catch {
|
|
644
|
+
/* refused below */
|
|
645
|
+
}
|
|
646
|
+
if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
|
|
647
|
+
try {
|
|
648
|
+
resetFolders(foldersFile, foldersKey);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
|
|
651
|
+
}
|
|
652
|
+
tamperedReason = null;
|
|
653
|
+
foldersSeen = true;
|
|
654
|
+
ctx.logger?.warn?.('dsh-gpw: folder lists were reset by the operator; all entries cleared and re-signed');
|
|
655
|
+
return json({ ok: true, tampered: false, folders: folderLists() });
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
441
659
|
registry.register({
|
|
442
660
|
path: GPW_DECIDE_ROUTE,
|
|
443
661
|
methods: ['POST'],
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
|
|
|
283
283
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
284
284
|
* overrides it at runtime for tests and packagers.
|
|
285
285
|
*/
|
|
286
|
-
const PLUGIN_VERSION = '1.7.
|
|
286
|
+
const PLUGIN_VERSION = '1.7.9';
|
|
287
287
|
|
|
288
288
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
289
289
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|