@twinsearth/w2m-dsh-plugin 1.7.9 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.7.9",
15
+ "version": "1.8.0",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.7.9"
29
+ "version": "1.8.0"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.7.9",
4
+ "version": "1.8.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.8.0] — 2026-10-26
11
+
12
+ **GPW 增加四色审核 / 标记 / 提醒系统:绿(通过)、蓝(正常)、黄(警报)、红(风险)。**
13
+
14
+ - 新增 `src/plugin/gpw-colors.mjs`:把 DSH 各插件的审核行为归位到四色(只读/L1-L2/白名单命中/allow-list = 绿;
15
+ 本会话已批准过(指纹授权)= 蓝;越界写、非白名单命令、中风险 git/npm/网络操作、未知工具 = 黄;
16
+ 危险命令黑名单、敏感路径、系统路径、显式 deny 目录 = 红)。
17
+ - 门控改为颜色决策:绿/蓝直接放行(蓝来自会话内批准后的操作指纹);黄进入 GPW 待审批队列;红直接拒绝
18
+ (`tools.guard` + `tools/pre-execute` 双保险,拒绝理由带颜色语义)。
19
+ - 标记与提醒:`/api/w2m/gpw/state` 新增四色计数(colors)、待审批项与最近拦截项的颜色字段;
20
+ 面板显示四色图例与计数,待审批/最近条目带颜色标记;「允许本次会话」动作(allow-session)批准后,
21
+ 同一操作后续以蓝色(已授权常态)放行。
22
+ - 一句话语义:绿=不用管,蓝=管过了,黄=现在管,红=管也不让过(或必须人工硬确认)。
23
+ - 证据:`docs/acceptance/1.8.0/GPW-COLORS.json`。
10
24
  ## [1.7.9] — 2026-10-26
11
25
 
12
26
  **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.0", "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
+ }
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}',
@@ -658,6 +665,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
658
665
  h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
659
666
  ]),
660
667
  ]) : null,
668
+ h('div', { key: 'colours', className: 'w2mGpw_legend' }, ['green', 'blue', 'yellow', 'red'].map((colour) => h('span', { key: colour, className: 'w2mGpw_legendItem' }, [h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': colour }), String(state?.counters?.colors?.[colour] ?? 0)]))),
661
669
  h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
662
670
  h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
663
671
  h('input', {
@@ -682,7 +690,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
682
690
  recent.length === 0 ? h('div', { key: 'none', className: 'w2mGpw_note' }, t('gpwRecentEmpty')) : null,
683
691
  ...recent.map((item, index) => h('div', { key: 'r' + index, className: 'w2mGpw_recent' }, [
684
692
  h('div', { key: 'r1', className: 'w2mGpw_row' }, [
685
- h('span', { key: 'd', className: 'w2mGpw_tag', 'data-d': item.decision }, item.decision),
693
+ h('span', { key: 'd', className: 'w2mGpw_dot', 'data-c': item.color ?? 'green', title: item.decision }),
686
694
  h('span', { key: 't', className: 'w2mGpw_path', title: item.paths?.[0] ?? '' }, (item.folder ?? item.paths?.[0] ?? item.tool)),
687
695
  ]),
688
696
  item.folder === null || item.folder === undefined ? null : h('div', { key: 'r2', className: 'w2mGpw_actions' }, [
@@ -695,7 +703,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
695
703
  pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
696
704
  h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
697
705
  ...pending.map((item) => h('div', { key: item.id, className: 'w2mGpw_recent' }, [
698
- h('div', { key: 'p1', className: 'w2mGpw_path' }, item.tool + ' · ' + (item.reason ?? '')),
706
+ h('div', { key: 'p1', className: 'w2mGpw_path' }, [h('span', { key: 'c', className: 'w2mGpw_dot', 'data-c': item.color ?? 'yellow' }), item.tool + ' · ' + (item.reason ?? '')]),
699
707
  ...(Array.isArray(item.paths) ? item.paths.slice(0, 3).map((p, i) => h('div', { key: 'p' + i, className: 'w2mGpw_path' }, p)) : []),
700
708
  h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
701
709
  h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
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.0",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs 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",
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",
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.0",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -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
+ }
@@ -40,6 +40,7 @@ import { dirname, join, resolve } from 'node:path';
40
40
  import { createHmac, randomBytes, randomUUID } from 'node:crypto';
41
41
 
42
42
  import { classifyToolCall } from './folder-policy.mjs';
43
+ import { classifyGpwCall, fingerprintOf } from './gpw-colors.mjs';
43
44
 
44
45
  export const name = 'dsh-gpw';
45
46
 
@@ -361,6 +362,10 @@ export function apply(ctx, config = {}) {
361
362
  const pending = new Map();
362
363
  /** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
363
364
  const recent = [];
365
+ /** Four-colour counters: every decision lands in exactly one bucket (v1.7.10). */
366
+ const colorCounts = { green: 0, blue: 0, yellow: 0, red: 0 };
367
+ /** Operation fingerprints approved earlier in this session -> the blue ("正常") state. */
368
+ const sessionGrants = new Set();
364
369
 
365
370
  const noteRecent = (entry) => {
366
371
  recent.unshift({ at: Date.now(), ...entry });
@@ -419,6 +424,8 @@ export function apply(ctx, config = {}) {
419
424
  tool: String(toolCallOf(exec).name ?? 'tool'),
420
425
  reason,
421
426
  paths,
427
+ color: 'yellow',
428
+ fingerprint: fingerprintOf(toolCallOf(exec)),
422
429
  };
423
430
  pending.set(id, entry);
424
431
  pendingTotal += 1;
@@ -441,9 +448,10 @@ export function apply(ctx, config = {}) {
441
448
  } catch {
442
449
  /* already fired */
443
450
  }
444
- if (decision === 'allow') {
451
+ if (decision === 'allow' || decision === 'allow-session') {
445
452
  allowedTotal += 1;
446
- entry.resolve({ kind: 'allow', reason: 'GPW: allowed by the operator' });
453
+ if (typeof entry.fingerprint === 'string' && entry.fingerprint !== '') sessionGrants.add(entry.fingerprint);
454
+ entry.resolve({ kind: 'allow', reason: decision === 'allow-session' ? 'GPW: allowed for this session (blue)' : 'GPW: allowed by the operator' });
447
455
  } else {
448
456
  deniedTotal += 1;
449
457
  entry.resolve({ kind: 'deny', reason: 'GPW: denied by the operator' });
@@ -521,7 +529,6 @@ export function apply(ctx, config = {}) {
521
529
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
522
530
  return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
523
531
  }
524
- if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
525
532
  let cwd = null;
526
533
  try {
527
534
  cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
@@ -531,21 +538,52 @@ export function apply(ctx, config = {}) {
531
538
  // A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
532
539
  // subfolder reference is classified rather than treated as "names no path".
533
540
  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();
541
+ // v1.7.10: one four-colour decision in front of the folder plan, so every audit behaviour in DSH has one home.
542
+ const fingerprint = fingerprintOf(call);
543
+ const colour = classifyGpwCall({
544
+ name: call.name,
545
+ arguments: call.arguments,
546
+ paths: classification.paths,
547
+ whitelist: lists.whitelist,
548
+ blacklist: lists.blacklist,
549
+ workspace: cwd ?? null,
550
+ authorized: sessionGrants.has(fingerprint),
551
+ });
552
+ if (colour.color === 'red') {
553
+ deniedTotal += 1;
554
+ colorCounts.red += 1;
555
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: colour.reason, paths: classification.paths });
556
+ ctx.logger?.warn?.(`dsh-gpw: red -- refused ${String(call.name ?? 'tool')} -- ${colour.reason}`);
557
+ return { kind: 'deny', reason: `GPW red: ${colour.reason}` };
558
+ }
559
+ if (colour.color === 'green' || colour.color === 'blue') {
560
+ colorCounts[colour.color] += 1;
561
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: colour.color, reason: colour.reason, paths: classification.paths });
562
+ ctx.logger?.info?.(`dsh-gpw: ${colour.color} -- ${String(call.name ?? 'tool')} -- ${colour.reason}`);
563
+ return { kind: 'allow' };
564
+ }
565
+ if (classification.decision === 'pass' && classification.paths.length === 0) {
566
+ colorCounts.yellow += 1;
567
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: colour.reason, paths: [] });
568
+ return waitForOperator(exec, colour.reason, []);
569
+ }
535
570
  const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
536
571
  const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
537
572
  if (plan.kind === 'deny') {
538
573
  deniedTotal += 1;
539
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: plan.reason, paths: classification.paths });
574
+ colorCounts.red += 1;
575
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', color: 'red', reason: plan.reason, paths: classification.paths });
540
576
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${plan.reason}`);
541
577
  return { kind: 'deny', reason: `GPW: ${plan.reason}` };
542
578
  }
543
579
  if (plan.kind === 'allow') {
544
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', reason: plan.reason, paths: classification.paths });
580
+ colorCounts.green += 1;
581
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'allow', color: 'green', reason: plan.reason, paths: classification.paths });
545
582
  ctx.logger?.info?.(`dsh-gpw: auto-allowed ${String(call.name ?? 'tool')} -- ${plan.reason}`);
546
583
  return { kind: 'allow' };
547
584
  }
548
- noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', reason: plan.reason, paths: classification.paths });
585
+ colorCounts.yellow += 1;
586
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'pending', color: 'yellow', reason: plan.reason, paths: classification.paths });
549
587
  ctx.logger?.info?.(`dsh-gpw: holding ${String(call.name ?? 'tool')} for operator -- ${plan.reason}`);
550
588
  return waitForOperator(exec, plan.reason, classification.paths);
551
589
  });
@@ -569,11 +607,12 @@ export function apply(ctx, config = {}) {
569
607
  folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
570
608
  tampered: lists.tampered,
571
609
  tampered_reason: lists.reason,
572
- counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
610
+ counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
573
611
  recent: recent.map((entry) => ({
574
612
  at: entry.at,
575
613
  tool: entry.tool,
576
614
  decision: entry.decision,
615
+ color: entry.color ?? null,
577
616
  reason: entry.reason,
578
617
  paths: entry.paths,
579
618
  folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
@@ -667,8 +706,8 @@ export function apply(ctx, config = {}) {
667
706
  } catch {
668
707
  /* refused below */
669
708
  }
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);
709
+ if (typeof body?.id !== 'string' || (body?.decision !== 'allow' && body?.decision !== 'deny' && body?.decision !== 'allow-session')) {
710
+ return json({ ok: false, error: 'id and decision (allow|allow-session|deny) are required' }, 400);
672
711
  }
673
712
  const result = decidePending(body.id, body.decision);
674
713
  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.0';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;