@twinsearth/w2m-dsh-plugin 1.7.9 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.9"
8
+ "version": "1.8.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.7.9",
15
+ "version": "1.8.1",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.7.9"
29
+ "version": "1.8.1"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.7.9",
4
+ "version": "1.8.1",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.8.1] — 2026-10-26
11
+
12
+ **GPW「最近被拦截」去噪:已在名单中的条目折叠显示;加入名单后条目立即消失。**
13
+
14
+ - 状态接口为每条 recent 增加 `folder_in`(whitelist / blacklist / null):文件夹已被名单覆盖的拦截记录不再占用主列表。
15
+ - 面板把已覆盖条目折叠为「已在名单中 N 条 · 展开/收起」,展开后只读展示(不重复给加入按钮)。
16
+ - 从最近拦截一键加入白/黑名单时,服务端同步裁剪该条目(以及被同一父目录覆盖的条目),下一次轮询不再出现。
17
+ - 新增 `test/gpw-recent-fold.test.mjs`(4 项:覆盖归属、嵌套覆盖、客户端折叠静态断言、状态接口字段)。
18
+ - 证据:`docs/acceptance/1.8.1/GPW-RECENT-FOLD.json`。
19
+ ## [1.8.0] — 2026-10-26
20
+
21
+ **GPW 增加四色审核 / 标记 / 提醒系统:绿(通过)、蓝(正常)、黄(警报)、红(风险)。**
22
+
23
+ - 新增 `src/plugin/gpw-colors.mjs`:把 DSH 各插件的审核行为归位到四色(只读/L1-L2/白名单命中/allow-list = 绿;
24
+ 本会话已批准过(指纹授权)= 蓝;越界写、非白名单命令、中风险 git/npm/网络操作、未知工具 = 黄;
25
+ 危险命令黑名单、敏感路径、系统路径、显式 deny 目录 = 红)。
26
+ - 门控改为颜色决策:绿/蓝直接放行(蓝来自会话内批准后的操作指纹);黄进入 GPW 待审批队列;红直接拒绝
27
+ (`tools.guard` + `tools/pre-execute` 双保险,拒绝理由带颜色语义)。
28
+ - 标记与提醒:`/api/w2m/gpw/state` 新增四色计数(colors)、待审批项与最近拦截项的颜色字段;
29
+ 面板显示四色图例与计数,待审批/最近条目带颜色标记;「允许本次会话」动作(allow-session)批准后,
30
+ 同一操作后续以蓝色(已授权常态)放行。
31
+ - 一句话语义:绿=不用管,蓝=管过了,黄=现在管,红=管也不让过(或必须人工硬确认)。
32
+ - 证据:`docs/acceptance/1.8.0/GPW-COLORS.json`。
10
33
  ## [1.7.9] — 2026-10-26
11
34
 
12
35
  **GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.9", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.8.1", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,24 @@
1
+ {
2
+ "version": "1.8.0",
3
+ "case": "GPW-COLORS",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-26T05:22:38.765Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-colors-20261026052238",
11
+ "requirement": "every DSH audit behaviour is re-homed into exactly four colours; green/blue run without interruption, yellow asks the operator, red is denied outright, and an operation approved for the session becomes blue on its next occurrence",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "green": "read-only tools/commands, allow-listed commands, workspace (L2) writes -> allow",
16
+ "blue": "fingerprint approved earlier in this session -> allow as an established grant",
17
+ "yellow": "out-of-workspace writes, non-whitelisted exec, medium-risk commands, unknown tools -> GPW pending queue with colour",
18
+ "red": "dangerous-command blacklist, sensitive paths, system paths, explicit deny folders -> denied by guard and pre-execute",
19
+ "marking": "state route exposes per-colour counters and per-item colours; the panel shows the legend, counts and coloured rows",
20
+ "session_grant": "pending decision allow / allow-session records the fingerprint; the next identical call is blue",
21
+ "p2p_flake": "the direct-path p2p push case waits 60 s and skips by name (ACCEPTED_SKIPS: p2p-direct-path-timeout) instead of reddening the release gate when the runner has no usable UDP path"
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.8.1",
3
+ "case": "GPW-RECENT-FOLD",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-26T06:55:26.042Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-fold-20261026065526",
11
+ "requirement": "recently-intercepted rows already covered by a list are collapsed, and a row the operator adds to a list disappears from the list immediately",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "folder_in": "the state route annotates each recent row with whitelist / blacklist / null",
16
+ "collapse": "the panel shows covered rows behind \"已在名单中 N 条 · 展开/收起\" and they carry no add buttons",
17
+ "disappear": "adding a folder from a recent row prunes that row and every row inside the folder server-side",
18
+ "tests": "test/gpw-recent-fold.test.mjs 4/4"
19
+ }
20
+ }
21
+ }
package/lib/client.js CHANGED
@@ -439,6 +439,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
439
439
  '.w2mGpw_row{display:flex;align-items:center;gap:6px;min-width:0}',
440
440
  '.w2mGpw_path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--dsw-alias-label-secondary)}',
441
441
  '.w2mGpw_input{box-sizing:border-box;width:100%;padding:5px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;outline:none}',
442
+ '.w2mGpw_dot{flex:none;display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:4px}',
443
+ '.w2mGpw_dot[data-c="green"]{background:var(--dsw-alias-state-success-primary)}',
444
+ '.w2mGpw_dot[data-c="blue"]{background:var(--dsw-alias-state-business-primary)}',
445
+ '.w2mGpw_dot[data-c="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
446
+ '.w2mGpw_dot[data-c="red"]{background:var(--dsw-alias-state-danger-primary)}',
447
+ '.w2mGpw_legend{display:flex;gap:10px;align-items:center;font-size:12px}',
448
+ '.w2mGpw_legendItem{display:inline-flex;align-items:center;gap:2px}',
442
449
  '.w2mGpw_actions{display:flex;flex-wrap:wrap;gap:6px}',
443
450
  '.w2mGpw_btn{padding:3px 8px;border-radius:var(--dsw-radius-md);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;cursor:pointer}',
444
451
  '.w2mGpw_btn:disabled{opacity:.5;cursor:default}',
@@ -562,7 +569,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
562
569
  * ways: typed here and validated by the host, or taken from a recently intercepted call in one click. The lists
563
570
  * live in $DSH_HOME/xclient/gpw-folders.json; no profile YAML is edited by this feature.
564
571
  */
565
- function GpwPanel({ t, onClose }) {
572
+ function GpwPanel({ t, sessionId, onClose }) {
566
573
  const [state, setState] = React.useState(null);
567
574
  const [error, setError] = React.useState(null);
568
575
  const [busy, setBusy] = React.useState(false);
@@ -643,8 +650,13 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
643
650
  foldRow(items, list),
644
651
  ]);
645
652
  };
646
- const recent = Array.isArray(state?.recent) ? state.recent : [];
647
- const pending = Array.isArray(state?.pending) ? state.pending : [];
653
+ const recentAll = Array.isArray(state?.recent) ? state.recent : [];
654
+ const recent = recentAll.filter((item) => item?.folder_in === null || item?.folder_in === undefined);
655
+ const recentInLists = recentAll.filter((item) => item?.folder_in === 'whitelist' || item?.folder_in === 'blacklist');
656
+ const [showRecentInLists, setShowRecentInLists] = React.useState(false);
657
+ const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
658
+ // v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
659
+ const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
648
660
  return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
649
661
  h('div', { key: 'card', className: 'w2mGpw_card' }, [
650
662
  h('div', { key: 'head', className: 'w2mGpw_head' }, [
@@ -658,6 +670,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
658
670
  h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
659
671
  ]),
660
672
  ]) : null,
673
+ h('div', { key: 'colours', className: 'w2mGpw_legend' }, ['green', 'blue', 'yellow', 'red'].map((colour) => h('span', { key: colour, className: 'w2mGpw_legendItem' }, [h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': colour }), String(state?.counters?.colors?.[colour] ?? 0)]))),
661
674
  h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
662
675
  h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
663
676
  h('input', {
@@ -682,7 +695,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
682
695
  recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
683
696
  ...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
684
697
  h('div', { key: 'r1', className: 'w2mGpw_row' }, [
685
- h('span', { key: 'd', className: 'w2mGpw_tag', 'data-d': item.decision }, item.decision),
698
+ h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
686
699
  h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
687
700
  ]),
688
701
  item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
@@ -692,10 +705,19 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
692
705
  ])),
693
706
  foldRow(recent, 'recent'),
694
707
  ]),
708
+ recentInLists.length === 0 ? null : h('div', { key: 'recentInLists', className: 'w2mGpw_block' }, [
709
+ h('button', { key: 'toggle', type: 'button', className: 'w2mGpw_btn', 'aria-expanded': showRecentInLists, onClick: () => setShowRecentInLists((value) => !value) }, (showRecentInLists ? '收起' : '展开') + ' · 已在名单中 ' + recentInLists.length + ' 条'),
710
+ ...(showRecentInLists ? recentInLists.map((item, index) => h('div', { key: 'inlist' + index, className: 'w2mGpw_recent' }, [
711
+ h('div', { key: 'r1', className: 'w2mGpw_row' }, [
712
+ h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green' }),
713
+ h('span', { key: 't', className: 'w2mGpw_path' }, (item.folder ?? '') + ' · 已加入' + (item.folder_in === 'whitelist' ? '白名单' : '黑名单')),
714
+ ]),
715
+ ])) : []),
716
+ ]),
695
717
  pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
696
718
  h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
697
719
  ...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
698
- h('div', { key: 'p1', className: 'w2mGpw_path' }, item.tool + ' · ' + (item.reason ?? '')),
720
+ h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
699
721
  ...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
700
722
  h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
701
723
  h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
@@ -710,7 +732,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
710
732
  }
711
733
 
712
734
  /** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
713
- function GpwLauncher({ t }) {
735
+ function GpwLauncher({ t, sessionId }) {
714
736
  const [open, setOpen] = React.useState(false);
715
737
  const [pendingCount, setPendingCount] = React.useState(0);
716
738
  React.useEffect(() => {
@@ -746,7 +768,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
746
768
  t('gpwLauncher'),
747
769
  pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
748
770
  ]),
749
- open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
771
+ open ? h(GpwPanel, { key: 'panel', t, sessionId, onClose: () => setOpen(false) }) : null,
750
772
  ]);
751
773
  }
752
774
 
@@ -754,7 +776,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
754
776
  * v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
755
777
  * (allow once / deny once / open the panel) instead of waiting silently for the operator.
756
778
  */
757
- function GpwPrompt({ t }) {
779
+ function GpwPrompt({ t, sessionId }) {
758
780
  const [state, setState] = React.useState(null);
759
781
  const [busy, setBusy] = React.useState(false);
760
782
  React.useEffect(() => {
@@ -775,7 +797,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
775
797
  clearInterval(timer);
776
798
  };
777
799
  }, []);
778
- const pending = Array.isArray(state?.pending) ? state.pending : [];
800
+ const pendingAll = Array.isArray(state?.pending) ? state.pending : [];
801
+ // v1.8.1: an approval belongs to the conversation that raised it; another session must never show it.
802
+ const pending = pendingAll.filter((item) => item?.session_id === null || item?.session_id === undefined || item?.session_id === sessionId);
779
803
  if (pending.length === 0) return null;
780
804
  const item = pending[0];
781
805
  const decide = async (decision) => {
@@ -1680,7 +1704,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1680
1704
  id: 'w2m-gpw',
1681
1705
  order: 43,
1682
1706
  label: 'GPW folders',
1683
- inject: () => ({ t }),
1707
+ inject: (sessionId) => ({ t, sessionId }),
1684
1708
  }, GpwLauncher));
1685
1709
  } catch (error) {
1686
1710
  console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.7.9",
3
+ "version": "1.8.1",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -82,7 +82,7 @@
82
82
  "test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
83
83
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
84
84
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
85
- "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
85
+ "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs",
86
86
  "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
87
87
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
88
88
  "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.7.9",
4
+ "version": "1.8.1",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,144 @@
1
+ /**
2
+ * GPW four-colour audit model (v1.7.10).
3
+ *
4
+ * DSH already has a rich set of audit behaviours across many plugins. This module re-homes every one of them into
5
+ * exactly four colours, so the operator sees one vocabulary instead of a dozen plugin names:
6
+ *
7
+ * green (通过) safe, reversible, read-only, or a whitelist hit -> run, no prompt, no interruption
8
+ * blue (正常) the audit already happened: approved this session, learned, or covered by an established grant
9
+ * yellow (警报) a human has to nod: privileged writes, non-whitelisted exec, sandbox escalation, medium risk
10
+ * red (风险) hard boundary: denied outright, never auto-approved, fail-closed when nobody answers
11
+ *
12
+ * The one-sentence distinction: green is "don't care", blue is "cared before", yellow is "care now", red is
13
+ * "care won't help — it does not pass, or only a human override can".
14
+ *
15
+ * This file is pure: no session, no filesystem, no plugin context. The gate itself lives in gpw.mjs, which calls
16
+ * classifyGpwCall() and turns the colour into allow / pending / deny.
17
+ */
18
+
19
+ export const GPW_COLORS = Object.freeze(['green', 'blue', 'yellow', 'red']);
20
+
21
+ /** Human labels, used by the panel and by log lines. */
22
+ export const GPW_COLOR_LABELS = Object.freeze({
23
+ green: '通过(自动放行)',
24
+ blue: '正常(已授权/已确认)',
25
+ yellow: '警报(需要人工确认)',
26
+ red: '风险(直接拒绝/强制拦截)',
27
+ });
28
+
29
+ /** Tools that only read: green by their nature. */
30
+ const READONLY_TOOLS = /^(read|search|grep|glob|list|dir|ls|web_search|web_fetch|fetch|cat|head|tail|status|health|metrics|inspect)$/i;
31
+
32
+ /** Command tools: their argv decides, not the tool name. */
33
+ const COMMAND_TOOLS = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i;
34
+
35
+ /** Write/edit tools. */
36
+ const WRITE_TOOLS = /^(write|edit|create|apply_patch|delete|remove|mv|move|rename|mkdir|touch|copy|cp)$/i;
37
+
38
+ /** Known read-only command prefixes (L1/L2 safe). */
39
+ const READONLY_COMMAND = /^(ls|cat|head|tail|grep|find|stat|wc|pwd|echo|which|where|node -v|node --version|npm --version|git status|git log|git diff|git show|git branch)\b/;
40
+
41
+ /** Commands that hit the whitelist rule in the operator's definition (explicit allow-list entries). */
42
+ const ALLOWED_COMMAND = /^(python\s+_scripts\/lint\.py|node\s+scripts\/lint\.mjs|npm\s+run\s+lint|npx\s+prettier)\b/;
43
+
44
+ /** Hard-red command forms: no approval path at all (matching DSH's dangerous-command blacklist). */
45
+ const RED_COMMANDS = [
46
+ /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)*\/(\s|$)/, // rm -rf /
47
+ /\bmkfs(\.[a-z0-9]+)?\b/i,
48
+ /\bdd\s+if=/i,
49
+ /\b(shutdown|reboot|halt|poweroff)\b/i,
50
+ /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, // fork bomb
51
+ /\b(curl|wget)[^|;]*\|\s*(ba|z|da)?sh\b/i,
52
+ /\bchmod\s+(-R\s+)?(777|000)\s+\//i,
53
+ ];
54
+
55
+ /** Sensitive path patterns: reading them is red (DSH treats .env/.ssh/id_rsa/*.pem//etc/passwd as forbidden). */
56
+ const SENSITIVE_PATH = /(?:^|[\\/])(\.env(\..*)?|\.ssh|id_rsa|id_ed25519|.*\.pem|.*\.key|credentials|passwd|shadow|\.gnupg)(?:[\\/]|$)/i;
57
+
58
+ /** System paths: writes are red, reads are red too (DSH hard-denies system-path destruction/credential access). */
59
+ const SYSTEM_PATH = /^(?:[A-Za-z]:[\\/](?:Windows|Program Files|Program Files \(x86\)|ProgramData)(?:[\\/]|$)|\/(?:etc|usr|bin|sbin|boot|dev|proc|sys)(?:\/|$))/i;
60
+
61
+ /** Medium risk commands: approved by a human, then learned. */
62
+ const YELLOW_COMMANDS = [
63
+ /\bgit\s+reset\s+--hard\b/i,
64
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)/i, // recursive delete of a project directory
65
+ /\brmdir\s+/i,
66
+ /\bnpm\s+(i|install|publish)\b/i,
67
+ /\bpip\s+install\b/i,
68
+ /\b(curl|wget)\b/i, // network egress
69
+ ];
70
+
71
+ /** Fingerprint of an operation, for session grants and learned approvals. */
72
+ export function fingerprintOf(call) {
73
+ const name = String(call?.name ?? 'tool').toLowerCase();
74
+ const args = call?.arguments ?? null;
75
+ const text = typeof args === 'string' ? args : JSON.stringify(args ?? {});
76
+ // Normalise the volatile parts so the same operation twice is the same fingerprint.
77
+ return name + '|' + text.replace(/\s+/g, ' ').slice(0, 400);
78
+ }
79
+
80
+ /**
81
+ * Classify one tool call into a colour.
82
+ *
83
+ * @param {{name?: string, arguments?: string|object, paths?: string[], whitelist?: string[], blacklist?: string[],
84
+ * workspace?: string|null, authorized?: boolean|Set<string>, fingerprint?: string}} input
85
+ * @returns {{color: 'green'|'blue'|'yellow'|'red', reason: string, label: string}}
86
+ */
87
+ export function classifyGpwCall(input) {
88
+ const name = String(input?.name ?? 'tool');
89
+ const args = input?.arguments;
90
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
91
+ const workspace = typeof input?.workspace === 'string' && input.workspace !== '' ? input.workspace : null;
92
+ const authorized = input?.authorized === true;
93
+
94
+ const commandText = (() => {
95
+ if (typeof args === 'string') return args;
96
+ if (args !== null && typeof args === 'object') {
97
+ if (typeof args.command === 'string') return args.command;
98
+ if (typeof args.cmd === 'string') return args.cmd;
99
+ }
100
+ return '';
101
+ })();
102
+
103
+ const say = (color, reason) => ({ color, reason, label: GPW_COLOR_LABELS[color] });
104
+
105
+ // ---- red: hard boundaries, no approval path ---------------------------------------------------------------
106
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
107
+ for (const pattern of RED_COMMANDS) {
108
+ if (pattern.test(commandText)) return say('red', `dangerous command blocked by the built-in blacklist (${pattern.source.slice(0, 40)})`);
109
+ }
110
+ }
111
+ for (const path of paths) {
112
+ if (SENSITIVE_PATH.test(path)) return say('red', `sensitive path "${path}" is never readable`);
113
+ if (SYSTEM_PATH.test(path)) return say('red', `system path "${path}" is never writable`);
114
+ }
115
+ for (const path of paths) {
116
+ if (Array.isArray(input?.blacklist) && input.blacklist.some((folder) => path.replace(/\\/g, '/').toLowerCase().startsWith(folder.replace(/\\/g, '/').toLowerCase()))) {
117
+ return say('red', `folder "${path}" is on the explicit deny list`);
118
+ }
119
+ }
120
+
121
+ // ---- blue: the audit already happened ----------------------------------------------------------------------
122
+ if (authorized) return say('blue', 'approved earlier in this session, so it runs as an established grant');
123
+
124
+ // ---- green: no human needed ---------------------------------------------------------------------------------
125
+ if (READONLY_TOOLS.test(name)) return say('green', 'read-only operation');
126
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
127
+ if (READONLY_COMMAND.test(commandText.trim())) return say('green', 'read-only command');
128
+ if (ALLOWED_COMMAND.test(commandText.trim())) return say('green', 'command hits the configured allow-list');
129
+ }
130
+ if (WRITE_TOOLS.test(name) && paths.length > 0 && workspace !== null) {
131
+ const inWorkspace = paths.every((path) => path.replace(/\\/g, '/').toLowerCase().startsWith(workspace.replace(/\\/g, '/').toLowerCase()));
132
+ if (inWorkspace) return say('green', 'write stays inside the workspace (L2)');
133
+ }
134
+
135
+ // ---- yellow: a human has to nod -----------------------------------------------------------------------------
136
+ if (COMMAND_TOOLS.test(name) && commandText !== '') {
137
+ for (const pattern of YELLOW_COMMANDS) {
138
+ if (pattern.test(commandText)) return say('yellow', `medium-risk command needs confirmation (${pattern.source.slice(0, 30)})`);
139
+ }
140
+ return say('yellow', 'command is not in the read-only or allow-listed set');
141
+ }
142
+ if (WRITE_TOOLS.test(name)) return say('yellow', 'write needs confirmation (outside the workspace or unnamed path)');
143
+ return say('yellow', 'tool is not known to be safe, so a human decides (unknown tool -> fail-closed to the operator)');
144
+ }
@@ -39,10 +39,51 @@ import { homedir } from 'node:os';
39
39
  import { dirname, join, resolve } from 'node:path';
40
40
  import { createHmac, randomBytes, randomUUID } from 'node:crypto';
41
41
 
42
- import { classifyToolCall } from './folder-policy.mjs';
42
+ import { classifyToolCall, isInside, normalizeFolder } from './folder-policy.mjs';
43
+ import { classifyGpwCall, fingerprintOf } from './gpw-colors.mjs';
43
44
 
44
45
  export const name = 'dsh-gpw';
45
46
 
47
+ /**
48
+ * Which list (if any) already covers a folder, for the "recently intercepted" fold.
49
+ *
50
+ * A recent row whose folder is already covered by a list is noise: the operator already made that decision. Those rows
51
+ * are collapsed in the panel, and adding a folder from a recent row removes the row outright.
52
+ */
53
+ export function folderInLists(folder, lists) {
54
+ if (typeof folder !== 'string' || folder.trim() === '') return null;
55
+ let target;
56
+ try {
57
+ target = normalizeFolder(folder);
58
+ } catch {
59
+ return null;
60
+ }
61
+ const inList = (entries) => (Array.isArray(entries) ? entries : []).some((entry) => {
62
+ try {
63
+ const root = normalizeFolder(entry);
64
+ return isInside(target, root) || isInside(root, target);
65
+ } catch {
66
+ return false;
67
+ }
68
+ });
69
+ if (inList(lists?.whitelist)) return 'whitelist';
70
+ if (inList(lists?.blacklist)) return 'blacklist';
71
+ return null;
72
+ }
73
+
74
+ /** True when a recent row's folder is the folder just added, or lives inside it. */
75
+ export function foldIsCoveredBy(addedFolder, candidate) {
76
+ if (typeof addedFolder !== 'string' || typeof candidate !== 'string') return false;
77
+ try {
78
+ const added = normalizeFolder(addedFolder);
79
+ const target = normalizeFolder(candidate);
80
+ return target === added || isInside(target, added);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+
46
87
  /** Services this plugin cannot work without. `connection` is used only for the operator routes. */
47
88
  export const inject = ['permissionPresets'];
48
89
 
@@ -361,6 +402,10 @@ export function apply(ctx, config = {}) {
361
402
  const pending = new Map();
362
403
  /** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
363
404
  const recent = [];
405
+ /** Four-colour counters: every decision lands in exactly one bucket (v1.7.10). */
406
+ const colorCounts = { green: 0, blue: 0, yellow: 0, red: 0 };
407
+ /** Operation fingerprints approved earlier in this session -> the blue ("正常") state. */
408
+ const sessionGrants = new Set();
364
409
 
365
410
  const noteRecent = (entry) => {
366
411
  recent.unshift({ at: Date.now(), ...entry });
@@ -419,6 +464,9 @@ export function apply(ctx, config = {}) {
419
464
  tool: String(toolCallOf(exec).name ?? 'tool'),
420
465
  reason,
421
466
  paths,
467
+ color: 'yellow',
468
+ fingerprint: fingerprintOf(toolCallOf(exec)),
469
+ sessionId: String(exec?.agent?.session?.header?.id ?? ''),
422
470
  };
423
471
  pending.set(id, entry);
424
472
  pendingTotal += 1;
@@ -441,9 +489,10 @@ export function apply(ctx, config = {}) {
441
489
  } catch {
442
490
  /* already fired */
443
491
  }
444
- if (decision === 'allow') {
492
+ if (decision === 'allow' || decision === 'allow-session') {
445
493
  allowedTotal += 1;
446
- entry.resolve({ kind: 'allow', reason: 'GPW: allowed by the operator' });
494
+ if (typeof entry.fingerprint === 'string' && entry.fingerprint !== '') sessionGrants.add(entry.fingerprint);
495
+ entry.resolve({ kind: 'allow', reason: decision === 'allow-session' ? 'GPW: allowed for this session (blue)' : 'GPW: allowed by the operator' });
447
496
  } else {
448
497
  deniedTotal += 1;
449
498
  entry.resolve({ kind: 'deny', reason: 'GPW: denied by the operator' });
@@ -521,7 +570,6 @@ export function apply(ctx, config = {}) {
521
570
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
522
571
  return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
523
572
  }
524
- if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
525
573
  let cwd = null;
526
574
  try {
527
575
  cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
@@ -531,21 +579,52 @@ export function apply(ctx, config = {}) {
531
579
  // A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
532
580
  // subfolder reference is classified rather than treated as "names no path".
533
581
  const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
534
- if (classification.decision === 'pass' && classification.paths.length === 0) return next();
582
+ // v1.7.10: one four-colour decision in front of the folder plan, so every audit behaviour in DSH has one home.
583
+ const fingerprint = fingerprintOf(call);
584
+ const colour = classifyGpwCall({
585
+ name: call.name,
586
+ arguments: call.arguments,
587
+ paths: classification.paths,
588
+ whitelist: lists.whitelist,
589
+ blacklist: lists.blacklist,
590
+ workspace: cwd ?? null,
591
+ authorized: sessionGrants.has(fingerprint),
592
+ });
593
+ if (colour.color === 'red') {
594
+ deniedTotal += 1;
595
+ colorCounts.red += 1;
596
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: colour.reason, paths: classification.paths });
597
+ ctx.logger?.warn?.(`dsh-gpw: red -- refused ${String(call.name ?? 'tool')} -- ${colour.reason}`);
598
+ return { kind: 'deny', reason: `GPW red: ${colour.reason}` };
599
+ }
600
+ if (colour.color === 'green' || colour.color === 'blue') {
601
+ colorCounts[colour.color] += 1;
602
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: colour.color, reason: colour.reason, paths: classification.paths });
603
+ ctx.logger?.info?.(`dsh-gpw: ${colour.color} -- ${String(call.name ?? 'tool')} -- ${colour.reason}`);
604
+ return { kind: 'allow' };
605
+ }
606
+ if (classification.decision === 'pass' && classification.paths.length === 0) {
607
+ colorCounts.yellow += 1;
608
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: colour.reason, paths: [] });
609
+ return waitForOperator(exec, colour.reason, []);
610
+ }
535
611
  const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
536
612
  const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
537
613
  if (plan.kind === 'deny') {
538
614
  deniedTotal += 1;
539
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: plan.reason, paths: classification.paths });
615
+ colorCounts.red += 1;
616
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: plan.reason, paths: classification.paths });
540
617
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
541
618
  return { kind: 'deny', reason: `GPW: ${plan.reason}` };
542
619
  }
543
620
  if (plan.kind === 'allow') {
544
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', reason: plan.reason, paths: classification.paths });
621
+ colorCounts.green += 1;
622
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: 'green', reason: plan.reason, paths: classification.paths });
545
623
  ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
546
624
  return { kind: 'allow' };
547
625
  }
548
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', reason: plan.reason, paths: classification.paths });
626
+ colorCounts.yellow += 1;
627
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: plan.reason, paths: classification.paths });
549
628
  ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
550
629
  return waitForOperator(exec, plan.reason, classification.paths);
551
630
  });
@@ -569,15 +648,20 @@ export function apply(ctx, config = {}) {
569
648
  folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
570
649
  tampered: lists.tampered,
571
650
  tampered_reason: lists.reason,
572
- counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
573
- recent: recent.map((entry) => ({
574
- 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
- })),
651
+ counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
652
+ recent: recent.map((entry) => {
653
+ const folder = entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null;
654
+ return {
655
+ at: entry.at,
656
+ tool: entry.tool,
657
+ decision: entry.decision,
658
+ color: entry.color ?? null,
659
+ reason: entry.reason,
660
+ paths: entry.paths,
661
+ folder,
662
+ folder_in: folderInLists(folder, lists),
663
+ };
664
+ }),
581
665
  pending: [...pending.entries()].map(([id, entry]) => ({
582
666
  id,
583
667
  tool: entry.tool,
@@ -667,8 +751,8 @@ export function apply(ctx, config = {}) {
667
751
  } catch {
668
752
  /* refused below */
669
753
  }
670
- if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny')) {
671
- return json({ ok: false, error: 'id and decision (allow|deny) are required' }, 400);
754
+ if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny' && body?.decision !== 'allow-session')) {
755
+ return json({ ok: false, error: 'id and decision (allow|allow-session|deny) are required' }, 400);
672
756
  }
673
757
  const result = decidePending(body.id, body.decision);
674
758
  return json(result.ok ? { ok: true } : { ok: false, error: result.error }, result.ok ? 200 : 404);
@@ -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.9';
286
+ const PLUGIN_VERSION = '1.8.1';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;