@twinsearth/w2m-dsh-plugin 2.0.3 → 2.0.5

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.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +187 -462
  5. package/bin/w2m-joint.mjs +69 -7
  6. package/codex/mcp-servers.toml +1 -1
  7. package/docs/acceptance/2.0.4/RELEASE-2040.json +22 -0
  8. package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
  9. package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
  10. package/docs/adr/0001-collaboration-crdt.md +59 -0
  11. package/lib/client.js +2754 -2599
  12. package/package.json +2 -2
  13. package/plugin.json +1 -1
  14. package/src/agent/agent.mjs +97 -36
  15. package/src/agent/artifact-delivery.mjs +89 -0
  16. package/src/agent/caps.mjs +16 -2
  17. package/src/agent/sinks/git-sink.mjs +78 -0
  18. package/src/agent/sinks/ssh-sink.mjs +83 -0
  19. package/src/agent/sse-cursor.mjs +16 -0
  20. package/src/agent/sse-watchdog.mjs +71 -0
  21. package/src/plugin/dataplane-sink-route.mjs +113 -0
  22. package/src/plugin/fleet-chat-route.mjs +11 -0
  23. package/src/plugin/gpw.mjs +99 -15
  24. package/src/plugin/joint-dev/dispatch.mjs +5 -1
  25. package/src/plugin/joint-dev/merge-approval.mjs +115 -0
  26. package/src/plugin/joint-dev/progress.mjs +107 -0
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
  28. package/src/plugin/project-stream-route.mjs +58 -0
  29. package/src/plugin/tools-joint.mjs +6 -1
  30. package/src/plugin/tools.mjs +25 -5
  31. package/src/relay/fleet-messages.mjs +48 -24
  32. package/src/relay/project-room.mjs +47 -2
  33. package/src/relay/server.mjs +11 -5
  34. package/src/relay/state.mjs +63 -3
  35. package/src/util/chat-crypto.mjs +70 -1
  36. package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
  37. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
  38. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
  39. package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
  40. package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
  41. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
  42. package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
  43. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
  44. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
  45. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
  46. package/docs/COLLAB/TASK-MODEL.md +0 -281
  47. package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
  48. package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
  49. package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
  50. package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
  51. package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
  52. package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
  53. package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
  54. package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
  55. package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
  56. package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
  57. package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
  58. package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
  59. package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
  60. package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
  61. package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
  62. package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
  63. package/lib/client.js.bak2 +0 -640
  64. package/src/plugin/dataflow.mjs +0 -72
  65. package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
  66. package/src/plugin/fleet-target.mjs.bak-archive +0 -812
  67. package/src/plugin/tofuture/report.mjs +0 -44
  68. package/src/plugin/tools.mjs.bak-c7 +0 -7029
  69. package/src/relay/tofuture-whitelist.mjs +0 -46
  70. package/src/util/data-plane.mjs +0 -180
  71. package/src/util/sealed-object.mjs +0 -141
  72. package/src/util/tofuture-hmac.mjs +0 -30
@@ -0,0 +1,71 @@
1
+ /**
2
+ * SSE watchdog (P0-f): the stream must not hang forever on a half-open connection.
3
+ *
4
+ * Two deadlines are enforced:
5
+ * · first frame — nothing parsed within 5 s means the stream is not really attached;
6
+ * · idle — 45 s without any byte, which is longer than the relay's 15 s keepalive
7
+ * interval by design (three missed keepalives before we give up).
8
+ *
9
+ * `noteBytes` sustains the idle deadline (keepalive comments prove the socket is alive) but it does
10
+ * NOT satisfy the first-frame deadline: a stream that connects and sends keepalives without a
11
+ * parseable `ready` is exactly the buffered/half-dead case this watchdog exists to catch.
12
+ */
13
+ export const SSE_FIRST_FRAME_MS = 5000;
14
+ export const SSE_IDLE_MS = 45000;
15
+ export const SSE_FIRST_FRAME_TIMEOUT = 'SSE_FIRST_FRAME_TIMEOUT';
16
+ export const SSE_IDLE_TIMEOUT = 'SSE_IDLE_TIMEOUT';
17
+
18
+ export function createSseWatchdog(input = {}) {
19
+ const firstFrameMs = Number.isInteger(input.firstFrameMs) && input.firstFrameMs > 0 ? input.firstFrameMs : SSE_FIRST_FRAME_MS;
20
+ const idleMs = Number.isInteger(input.idleMs) && input.idleMs > 0 ? input.idleMs : SSE_IDLE_MS;
21
+ const onTimeout = typeof input.onTimeout === 'function' ? input.onTimeout : () => {};
22
+ let disposed = false;
23
+ let firstTimer = null;
24
+ let idleTimer = null;
25
+
26
+ const armIdle = () => {
27
+ if (disposed) return;
28
+ if (idleTimer !== null) clearTimeout(idleTimer);
29
+ idleTimer = setTimeout(() => {
30
+ if (disposed) return;
31
+ disposed = true;
32
+ clearTimers();
33
+ onTimeout(SSE_IDLE_TIMEOUT);
34
+ }, idleMs);
35
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
36
+ };
37
+ const clearTimers = () => {
38
+ if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
39
+ if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; }
40
+ };
41
+
42
+ firstTimer = setTimeout(() => {
43
+ if (disposed) return;
44
+ disposed = true;
45
+ clearTimers();
46
+ onTimeout(SSE_FIRST_FRAME_TIMEOUT);
47
+ }, firstFrameMs);
48
+ if (typeof firstTimer.unref === 'function') firstTimer.unref();
49
+ armIdle();
50
+
51
+ return {
52
+ /** Any byte (headers, keepalive comment, frame) proves the socket is alive: it clears the
53
+ * first-frame deadline and re-arms the idle deadline. */
54
+ noteBytes() {
55
+ if (disposed) return;
56
+ armIdle();
57
+ },
58
+ /** A parsed frame clears the first-frame deadline. */
59
+ noteFrame() {
60
+ if (disposed) return;
61
+ if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
62
+ armIdle();
63
+ },
64
+ get disposed() { return disposed; },
65
+ dispose() {
66
+ if (disposed) return;
67
+ disposed = true;
68
+ clearTimers();
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `/api/w2m/dataplane-sink` (A3): the settings-page half of the S4 data-plane authorization.
3
+ *
4
+ * GET -> { ok, configured, kind, root|host/user/dir, saved_at }
5
+ * POST -> { ok, configured: true, kind, ... } and writes <stateDir>/dataplane/sink.json (0600)
6
+ *
7
+ * Authorization is a LOCAL USER action through this host route. Model/tool parameters can never
8
+ * authorize a sink: the tool schemas do not expose this path, and this route does not accept an
9
+ * operator/device token as the authority (the host's same-origin fence is the boundary).
10
+ */
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
12
+ import { dirname, isAbsolute, join, relative } from 'node:path';
13
+ import { randomBytes } from 'node:crypto';
14
+
15
+ export const DATAPLANE_SINK_ROUTE_PATH = '/api/w2m/dataplane-sink';
16
+ const NO_STORE = { 'cache-control': 'no-store' };
17
+ const KINDS = new Set(['dir', 'lan-git', 'ssh']);
18
+ const CREDENTIAL_KEYS = /^(?:password|passphrase|key|private_key|token|secret|credential|identity)$/i;
19
+
20
+ function configPath(stateDir) {
21
+ return join(stateDir, 'dataplane', 'sink.json');
22
+ }
23
+
24
+ function readConfig(stateDir) {
25
+ const file = configPath(stateDir);
26
+ if (!existsSync(file)) return null;
27
+ try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return null; }
28
+ }
29
+
30
+ function insideStateDir(stateDir, target) {
31
+ const rel = relative(stateDir, target);
32
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
33
+ }
34
+
35
+ /** Strip anything that looks like a secret; the file only holds references, never credentials. */
36
+ function publicView(config) {
37
+ if (config === null) return { configured: false, kind: null };
38
+ if (config.kind === 'ssh') return { configured: true, kind: config.kind, host: config.host ?? null, user: config.user ?? null, dir: config.dir ?? null, port: config.port ?? null, saved_at: config.saved_at ?? null };
39
+ return { configured: true, kind: config.kind, root: config.root ?? null, remote: config.remote ?? null, branch: config.branch ?? null, saved_at: config.saved_at ?? null };
40
+ }
41
+
42
+ /**
43
+ * @param {object} ctx Cordis context
44
+ * @param {{stateDir?: () => string|null}} [deps]
45
+ */
46
+ export function registerDataplaneSinkRoute(ctx, deps = {}) {
47
+ const status = { mounted: false, reason: 'the host has no Connection service' };
48
+ if (typeof ctx?.inject !== 'function') return status;
49
+ ctx.inject(['connection'], (webCtx) => {
50
+ const registry = webCtx?.connection?.fetch;
51
+ if (registry === undefined || typeof registry.register !== 'function') {
52
+ status.reason = 'the connection service carries no fetch registry';
53
+ return;
54
+ }
55
+ const stateDirOf = () => (typeof deps?.stateDir === 'function' ? deps.stateDir() : null);
56
+ registry.register({
57
+ path: DATAPLANE_SINK_ROUTE_PATH,
58
+ methods: ['GET', 'POST'],
59
+ requestBody: 'buffered',
60
+ fetch: async (request) => {
61
+ const stateDir = stateDirOf();
62
+ if (typeof stateDir !== 'string' || stateDir === '') {
63
+ return Response.json({ ok: false, code: 'DATAPLANE_STATE_UNAVAILABLE' }, { status: 503, headers: NO_STORE });
64
+ }
65
+ if (request.method === 'GET') {
66
+ return Response.json({ ok: true, ...publicView(readConfig(stateDir)) }, { headers: NO_STORE });
67
+ }
68
+ let body = null;
69
+ try { body = await request.json(); } catch { /* refused below */ }
70
+ if (body === null || typeof body !== 'object') return Response.json({ ok: false, code: 'SINK_BODY_REQUIRED' }, { status: 400, headers: NO_STORE });
71
+ for (const key of Object.keys(body)) {
72
+ if (CREDENTIAL_KEYS.test(key)) return Response.json({ ok: false, code: 'SINK_CREDENTIALS_NOT_ACCEPTED', detail: key }, { status: 400, headers: NO_STORE });
73
+ }
74
+ const kind = typeof body.kind === 'string' ? body.kind : '';
75
+ if (!KINDS.has(kind)) return Response.json({ ok: false, code: 'SINK_KIND_INVALID' }, { status: 400, headers: NO_STORE });
76
+ const config = { v: 1, kind, saved_at: new Date().toISOString(), approved_by: 'local-user' };
77
+ if (kind === 'ssh') {
78
+ if (typeof body.host !== 'string' || !/^[A-Za-z0-9.-]{1,253}$/.test(body.host)) return Response.json({ ok: false, code: 'SINK_SSH_HOST_INVALID' }, { status: 400, headers: NO_STORE });
79
+ if (typeof body.user !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(body.user)) return Response.json({ ok: false, code: 'SINK_SSH_USER_INVALID' }, { status: 400, headers: NO_STORE });
80
+ if (typeof body.dir !== 'string' || !isAbsolute(body.dir)) return Response.json({ ok: false, code: 'SINK_SSH_DIR_INVALID' }, { status: 400, headers: NO_STORE });
81
+ if (body.port !== undefined && !(Number.isInteger(body.port) && body.port >= 1 && body.port <= 65535)) return Response.json({ ok: false, code: 'SINK_SSH_PORT_INVALID' }, { status: 400, headers: NO_STORE });
82
+ config.host = body.host;
83
+ config.user = body.user;
84
+ config.dir = body.dir;
85
+ if (body.port !== undefined) config.port = body.port;
86
+ } else {
87
+ if (typeof body.root !== 'string' || !isAbsolute(body.root)) return Response.json({ ok: false, code: 'SINK_ROOT_INVALID' }, { status: 400, headers: NO_STORE });
88
+ const resolved = realpathSync.native ? (() => { try { return realpathSync.native(body.root); } catch { return null; } })() : null;
89
+ if (resolved === null) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_FOUND' }, { status: 400, headers: NO_STORE });
90
+ const stat = statSync(resolved);
91
+ if (!stat.isDirectory()) return Response.json({ ok: false, code: 'SINK_ROOT_NOT_DIRECTORY' }, { status: 400, headers: NO_STORE });
92
+ if (insideStateDir(stateDir, resolved)) return Response.json({ ok: false, code: 'SINK_ROOT_INSIDE_STATE' }, { status: 400, headers: NO_STORE });
93
+ if (kind === 'lan-git' && !existsSync(join(resolved, '.git'))) return Response.json({ ok: false, code: 'SINK_GIT_NOT_A_REPO' }, { status: 400, headers: NO_STORE });
94
+ config.root = resolved;
95
+ if (kind === 'lan-git') {
96
+ if (typeof body.remote === 'string' && body.remote !== '') config.remote = body.remote;
97
+ if (typeof body.branch === 'string' && body.branch !== '') config.branch = body.branch;
98
+ }
99
+ }
100
+ const file = configPath(stateDir);
101
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
102
+ const tmp = file + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
103
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
104
+ renameSync(tmp, file);
105
+ try { chmodSync(file, 0o600); } catch { /* Windows ACL */ }
106
+ return Response.json({ ok: true, ...publicView(config) }, { headers: NO_STORE });
107
+ },
108
+ });
109
+ status.mounted = true;
110
+ status.reason = null;
111
+ });
112
+ return status;
113
+ }
@@ -23,6 +23,13 @@ import {
23
23
  } from './session-watch.mjs';
24
24
  import { createSessionSync } from './session-sync.mjs';
25
25
 
26
+ /** Session prompts are mirrored only when the operator opts in; the default is off. */
27
+ export const DEFAULT_MIRROR_MODE = 'off';
28
+ export function mirrorModeOf(cfg) {
29
+ const mode = cfg?.fleetChatMirror;
30
+ return mode === 'plaintext' || mode === 'encrypted' ? mode : DEFAULT_MIRROR_MODE;
31
+ }
32
+
26
33
 
27
34
  /**
28
35
  * The newest transcript for a cwd, or null. Reads directory listings and stat only -- this module never opens a
@@ -158,12 +165,16 @@ export function registerChatRoutes(ctx, deps) {
158
165
  * are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
159
166
  */
160
167
  const MIRROR_MAX_BYTES = 3500;
168
+
161
169
  const truncateUtf8 = (value, max) => {
162
170
  const bytes = Buffer.from(value, 'utf8');
163
171
  if (bytes.length <= max) return value;
164
172
  return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
165
173
  };
166
174
  const mirrorOnce = async () => {
175
+ const mode = mirrorModeOf(deps.cfg());
176
+ if (mode === 'off') return;
177
+ if (mode === 'encrypted') return; // encrypted mirroring lands with the AEAD v2 slice
167
178
  const identity = await identityOf();
168
179
  if (identity.conversationKey === null) return;
169
180
  const file = newestTranscript(projectDirOf());
@@ -95,7 +95,21 @@ export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
95
95
  export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
96
96
 
97
97
  const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
98
- const DEFAULT_PENDING_TIMEOUT_MS = 100_000;
98
+ const DEFAULT_PENDING_TIMEOUT_MS = 10_000;
99
+
100
+ /** Two policy strategies; novice is the product default. */
101
+ export const GPW_MODES = Object.freeze(['novice', 'advanced']);
102
+ export const DEFAULT_GPW_MODE = 'novice';
103
+ /** At most one path-naming auto-pass per 3 s in novice mode. */
104
+ export const NOVICE_APPROVE_GAP_MS = 3000;
105
+ /** Always refused in novice mode, even with an empty operator blacklist. */
106
+ export const DEFAULT_PROTECTED_FOLDERS = Object.freeze([
107
+ 'C:\\Windows',
108
+ 'C:\\Program Files',
109
+ 'D:\\Program Files',
110
+ 'C:\\Program Files (x86)',
111
+ 'D:\\Program Files (x86)',
112
+ ]);
99
113
  const RECENT_LIMIT = 20;
100
114
  const NO_STORE = { 'cache-control': 'no-store' };
101
115
  /** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
@@ -164,29 +178,30 @@ export function readFolders(file, key = null, options = {}) {
164
178
  raw = readFileSync(file, 'utf8');
165
179
  } catch (error) {
166
180
  if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
167
- return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
181
+ return { whitelist: [], blacklist: [], mode: 'novice', tampered: true, reason: 'folder lists file is missing' };
168
182
  }
169
183
  if (error?.code !== 'ENOENT') {
170
184
  return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
171
185
  }
172
- return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
186
+ return { whitelist: [], blacklist: [], mode: 'novice', tampered: false, reason: null, legacy: false };
173
187
  }
174
188
  let parsed = null;
175
189
  try {
176
190
  parsed = JSON.parse(raw);
177
191
  } catch {
178
- return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
192
+ return { whitelist: [], blacklist: [], mode: 'novice', tampered: true, reason: 'folder lists file is not valid JSON' };
179
193
  }
180
194
  const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
181
195
  const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
182
196
  if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
183
- return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
197
+ return { whitelist, blacklist, mode: 'novice', tampered: false, reason: null, legacy: true };
184
198
  }
185
- if (key === null) return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
199
+ const storedMode = parsed?.mode === 'advanced' ? 'advanced' : 'novice';
200
+ if (key === null) return { whitelist, blacklist, mode: storedMode, tampered: false, reason: null, legacy: false };
186
201
  if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
187
- return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
202
+ return { whitelist, blacklist, mode: storedMode, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
188
203
  }
189
- return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
204
+ return { whitelist, blacklist, mode: storedMode, tampered: false, reason: null, legacy: false };
190
205
  }
191
206
 
192
207
  /**
@@ -200,7 +215,8 @@ export function writeFolders(file, folders, key = null) {
200
215
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
201
216
  const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
202
217
  const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
203
- const next = { version: FOLDERS_VERSION, whitelist, blacklist };
218
+ const mode = folders?.mode === 'advanced' ? 'advanced' : 'novice';
219
+ const next = { version: FOLDERS_VERSION, whitelist, blacklist, mode };
204
220
  if (key !== null) {
205
221
  next.algo = FOLDERS_MAC_ALGO;
206
222
  next.mac = macOfFolders(whitelist, blacklist, key);
@@ -213,12 +229,12 @@ export function writeFolders(file, folders, key = null) {
213
229
  try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
214
230
  renameSync(tmp, file);
215
231
  try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
216
- return { whitelist, blacklist };
232
+ return { whitelist, blacklist, mode };
217
233
  }
218
234
 
219
235
  /** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
220
236
  export function resetFolders(file, key) {
221
- return writeFolders(file, { whitelist: [], blacklist: [] }, key);
237
+ return writeFolders(file, { whitelist: [], blacklist: [], mode: 'novice' }, key);
222
238
  }
223
239
 
224
240
  /**
@@ -363,6 +379,7 @@ export function apply(ctx, config = {}) {
363
379
  const minGapMs = positiveInt(config.minGapMs, DEFAULT_BUDGET.minGapMs);
364
380
  const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
365
381
  const requireFullAccess = config.requireFullAccess !== false;
382
+ const configuredMode = config.gpwMode === 'advanced' ? 'advanced' : DEFAULT_GPW_MODE;
366
383
  const foldersFile = foldersFileOf(config);
367
384
  const keyInfo = ensureFoldersKey(foldersFile, config);
368
385
  const foldersKey = keyInfo.key;
@@ -384,6 +401,8 @@ export function apply(ctx, config = {}) {
384
401
  noteTamper(initialFolders.reason);
385
402
  } else {
386
403
  // Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
404
+ // A configured mode is the bootstrap choice when the file carries none yet (tests and first run).
405
+ if (config.gpwMode !== undefined) initialFolders.mode = configuredMode;
387
406
  try {
388
407
  writeFolders(foldersFile, initialFolders, foldersKey);
389
408
  foldersSeen = true;
@@ -398,6 +417,7 @@ export function apply(ctx, config = {}) {
398
417
  let allowedTotal = 0;
399
418
  let pendingTotal = 0;
400
419
  let deniedTotal = 0;
420
+ let lastNovicePassAt = 0;
401
421
  /** id -> { resolve, createdAt, tool, reason, paths } */
402
422
  const pending = new Map();
403
423
  /** Newest-first record of calls the gate saw, so the panel can offer "add this folder" in one click. */
@@ -422,9 +442,13 @@ export function apply(ctx, config = {}) {
422
442
  foldersSeen = true;
423
443
  }
424
444
  const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
445
+ const mode = config.gpwMode !== undefined ? configuredMode : (stored.mode ?? configuredMode);
446
+ const storedBlacklist = merge(config.blacklist, stored.blacklist);
425
447
  return {
448
+ mode,
426
449
  whitelist: merge(config.whitelist, stored.whitelist),
427
- blacklist: merge(config.blacklist, stored.blacklist),
450
+ blacklist: storedBlacklist,
451
+ effectiveBlacklist: mode === 'novice' ? [...new Set([...DEFAULT_PROTECTED_FOLDERS, ...storedBlacklist])] : storedBlacklist,
428
452
  tampered: tamperedReason !== null,
429
453
  reason: tamperedReason,
430
454
  };
@@ -529,8 +553,8 @@ export function apply(ctx, config = {}) {
529
553
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
530
554
  return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
531
555
  }
532
- if (lists.blacklist.length === 0) return undefined;
533
- const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
556
+ if (lists.effectiveBlacklist.length === 0) return undefined;
557
+ const result = classifyToolCall({ ...call, blacklist: lists.effectiveBlacklist });
534
558
  if (result.decision !== 'deny') return undefined;
535
559
  deniedTotal += 1;
536
560
  noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
@@ -575,6 +599,34 @@ export function apply(ctx, config = {}) {
575
599
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
576
600
  return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
577
601
  }
602
+ if (lists.mode === 'novice') {
603
+ // 小白模式: only the blacklist restricts anything; every other call passes.
604
+ // At most one path-naming call per 3 s is auto-passed, so a burst cannot do unbounded damage.
605
+ let noviceCwd = null;
606
+ try {
607
+ noviceCwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
608
+ } catch {
609
+ noviceCwd = null;
610
+ }
611
+ const noviceCall = toolCallOf(exec);
612
+ const noviceClassification = classifyToolCall({ ...noviceCall, blacklist: lists.effectiveBlacklist, workspace: noviceCwd ?? null, base: noviceCwd ?? undefined });
613
+ if (noviceClassification.decision === 'deny') {
614
+ deniedTotal += 1;
615
+ colorCounts.red += 1;
616
+ noteRecent({ tool: String(noviceCall.name ?? 'tool'), decision: 'deny', color: 'red', reason: noviceClassification.reason, paths: noviceClassification.paths });
617
+ ctx.logger?.warn?.('dsh-gpw: novice -- refused ' + String(noviceCall.name ?? 'tool') + ' -- ' + noviceClassification.reason);
618
+ return { kind: 'deny', reason: 'GPW: ' + noviceClassification.reason + '; the call was not executed' };
619
+ }
620
+ if (noviceClassification.paths.length > 0) {
621
+ const waitMs = NOVICE_APPROVE_GAP_MS - (Date.now() - lastNovicePassAt);
622
+ if (waitMs > 0) await new Promise((resolveSlot) => { setTimeout(resolveSlot, Math.min(waitMs, NOVICE_APPROVE_GAP_MS)); });
623
+ lastNovicePassAt = Date.now();
624
+ }
625
+ colorCounts.green += 1;
626
+ const noviceReason = '小白模式:黑名单外全部放行(每 3 秒最多一次路径操作)';
627
+ noteRecent({ tool: String(noviceCall.name ?? 'tool'), decision: 'allow', color: 'green', reason: noviceReason, paths: noviceClassification.paths });
628
+ return { kind: 'allow', reason: noviceReason };
629
+ }
578
630
  let cwd = null;
579
631
  try {
580
632
  cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
@@ -651,6 +703,11 @@ export function apply(ctx, config = {}) {
651
703
  enabled: true,
652
704
  require_full_access: requireFullAccess,
653
705
  folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
706
+ mode: lists.mode,
707
+ modes: [...GPW_MODES],
708
+ protected_folders: lists.mode === 'novice' ? [...DEFAULT_PROTECTED_FOLDERS, ...lists.blacklist] : [...lists.blacklist],
709
+ pending_timeout_ms: pendingTimeoutMs,
710
+ novice_approve_gap_ms: NOVICE_APPROVE_GAP_MS,
654
711
  tampered: lists.tampered,
655
712
  tampered_reason: lists.reason,
656
713
  counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow, colors: { ...colorCounts }, grants: sessionGrants.size },
@@ -690,6 +747,22 @@ export function apply(ctx, config = {}) {
690
747
  } catch {
691
748
  /* refused below */
692
749
  }
750
+ if (body?.action === 'set-mode') {
751
+ const current = folderLists();
752
+ if (current.tampered) {
753
+ return json({ ok: false, error: 'folder lists are locked after an integrity failure (' + current.reason + '); reset them first' }, 409);
754
+ }
755
+ const mode = body?.mode;
756
+ if (mode !== 'novice' && mode !== 'advanced') return json({ ok: false, error: 'mode must be novice or advanced' }, 400);
757
+ const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
758
+ try {
759
+ writeFolders(foldersFile, { ...stored, mode }, foldersKey);
760
+ } catch (error) {
761
+ return json({ ok: false, error: 'could not write the policy mode: ' + (error?.message ?? error) }, 500);
762
+ }
763
+ ctx.logger?.info?.('dsh-gpw: policy mode -> ' + mode);
764
+ return json({ ok: true, mode, folders: folderLists() });
765
+ }
693
766
  const list = body?.list;
694
767
  if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
695
768
  if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
@@ -698,6 +771,17 @@ export function apply(ctx, config = {}) {
698
771
  return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
699
772
  }
700
773
  const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
774
+ if (body?.action === 'set-mode') {
775
+ const mode = body?.mode;
776
+ if (mode !== 'novice' && mode !== 'advanced') return json({ ok: false, error: 'mode must be novice or advanced' }, 400);
777
+ try {
778
+ writeFolders(foldersFile, { ...stored, mode }, foldersKey);
779
+ } catch (error) {
780
+ return json({ ok: false, error: 'could not write the policy mode: ' + (error?.message ?? error) }, 500);
781
+ }
782
+ ctx.logger?.info?.('dsh-gpw: policy mode -> ' + mode);
783
+ return json({ ok: true, mode, folders: folderLists() });
784
+ }
701
785
  if (body.action === 'remove') {
702
786
  if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
703
787
  const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
@@ -734,7 +818,7 @@ export function apply(ctx, config = {}) {
734
818
  }
735
819
  if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
736
820
  try {
737
- resetFolders(foldersFile, foldersKey);
821
+ resetFolders(foldersFile, foldersKey, folderLists().mode);
738
822
  } catch (error) {
739
823
  return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
740
824
  }
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
 
10
+ import { computeCommandHash, normalizeCwdRel, SHELL_ID_DIRECT } from '../../relay/state.mjs';
11
+
10
12
  import { appendAudit } from './audit-file.mjs';
11
13
  import { createAudit, inspectControlMessage } from './redline.mjs';
12
14
 
@@ -80,7 +82,8 @@ export function buildPlan(input) {
80
82
  step_id: step.step_id,
81
83
  machine_id: step.machine_id,
82
84
  command_argv: step.command_argv.slice(0, 32),
83
- command_hash: sha256(JSON.stringify(step.command_argv)),
85
+ cwd_rel: normalizeCwdRel(step.cwd_rel),
86
+ command_hash: computeCommandHash(step.command_argv, SHELL_ID_DIRECT, normalizeCwdRel(step.cwd_rel)),
84
87
  write_scope: Array.isArray(step.write_scope) ? step.write_scope.slice(0, 8) : [],
85
88
  depends_on: Array.isArray(step.depends_on) ? step.depends_on.slice(0, 8) : [],
86
89
  timeout_ms: Number.isInteger(step.timeout_ms) ? step.timeout_ms : null,
@@ -101,6 +104,7 @@ export function buildControlEnvelopes(plan) {
101
104
  base_commit: plan.base_commit,
102
105
  command_argv: step.command_argv,
103
106
  command_hash: step.command_hash,
107
+ cwd_rel: step.cwd_rel ?? '.',
104
108
  write_scope: step.write_scope,
105
109
  depends_on: step.depends_on,
106
110
  ...(step.timeout_ms === null ? {} : { timeout_ms: step.timeout_ms }),
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Merge approval (A4/D4): the second human signature in the joint-dev SOP.
3
+ *
4
+ * A plan approval authorises *sending work*; a merge approval authorises *moving a branch*. It binds
5
+ * the plan, repository, base commit, target ref, the old value the CAS will replace, and the exact
6
+ * integration head that was reviewed. Signed with the same operator Ed25519 key, TTL-capped, and
7
+ * consumed once (O_EXCL) so a replay cannot move a second branch.
8
+ */
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { ensureOperatorKey, keyIdOf, signApprovalPayload, verifyApprovalPayload } from '../operator-key.mjs';
14
+
15
+ const HEX64 = /^[0-9a-f]{64}$/;
16
+ const SHA = /^[0-9a-f]{7,40}$/;
17
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
18
+
19
+ export function bodyOf(input) {
20
+ const approvedAtIn = typeof input.approvedAt === 'string' ? input.approvedAt : (typeof input.approved_at === 'string' ? input.approved_at : null);
21
+ const expiresAtIn = typeof input.expiresAt === 'string' ? input.expiresAt : (typeof input.expires_at === 'string' ? input.expires_at : null);
22
+ const now = Number.isInteger(input.now) ? input.now : (approvedAtIn !== null ? Date.parse(approvedAtIn) : Date.now());
23
+ const approvedAt = approvedAtIn !== null ? approvedAtIn : new Date(now).toISOString();
24
+ const expiresAt = expiresAtIn !== null ? expiresAtIn : new Date(now + (Number.isInteger(input.ttlMs) ? input.ttlMs : 600_000)).toISOString();
25
+ return {
26
+ v: 1,
27
+ type: 'joint.merge',
28
+ plan_sha256: typeof input.planSha === 'string' && input.planSha !== '' ? input.planSha : (typeof input.plan_sha256 === 'string' && input.plan_sha256 !== '' ? input.plan_sha256 : null),
29
+ repo: String(input.repo ?? ''),
30
+ base_commit: String(input.baseCommit ?? input.base_commit ?? ''),
31
+ target_ref: String(input.targetRef ?? input.target_ref ?? ''),
32
+ expected_old: String(input.expectedOld ?? input.expected_old ?? ''),
33
+ integration_head: String(input.integrationHead ?? input.integration_head ?? ''),
34
+ approver: String(input.approver ?? ''),
35
+ authority: Number.isInteger(input.authority) ? input.authority : 0,
36
+ approved_at: approvedAt,
37
+ expires_at: expiresAt,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {{stateDir: string, planSha?: string|null, repo: string, baseCommit: string, targetRef: string,
43
+ * expectedOld: string, integrationHead: string, approver: string, authority: number,
44
+ * ttlMs?: number, now?: number, key?: {privateKey: string, publicKeyPem: string, key_id: string}}} input
45
+ */
46
+ export function createMergeApproval(input) {
47
+ if (!SHA.test(String(input.baseCommit ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_BASE_INVALID' };
48
+ if (!SHA.test(String(input.expectedOld ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_OLD_INVALID' };
49
+ if (!SHA.test(String(input.integrationHead ?? ''))) return { ok: false, code: 'MERGE_APPROVAL_HEAD_INVALID' };
50
+ if (!Number.isInteger(input.authority) || input.authority < 70 || input.authority > 100) return { ok: false, code: 'MERGE_APPROVAL_AUTHORITY_INVALID' };
51
+ const key = input.key ?? ensureOperatorKey();
52
+ if (key === null || key === undefined || key.ok !== true || key.privateKey === undefined) return { ok: false, code: key?.code ?? 'OPERATOR_KEY_MISSING' };
53
+ const body = bodyOf(input);
54
+ const approvalSha = sha256(JSON.stringify(body));
55
+ const record = {
56
+ ...body,
57
+ approval_sha256: approvalSha,
58
+ operator_key: { key_id: key.key_id ?? keyIdOf(key.publicKeyPem), public_key_pem: key.publicKeyPem },
59
+ sig: signApprovalPayload(body, key.privateKey),
60
+ };
61
+ const dir = join(input.stateDir, 'joint-dev', 'merge-approvals');
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ const path = join(dir, approvalSha + '.json');
64
+ const tmp = path + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
65
+ writeFileSync(tmp, JSON.stringify(record, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
66
+ renameSync(tmp, path);
67
+ try { chmodSync(path, 0o600); } catch { /* Windows ACL */ }
68
+ return { ok: true, approval: record, approval_sha256: approvalSha, path };
69
+ }
70
+
71
+ /**
72
+ * Verify signature, TTL and the exact binding the caller is about to apply.
73
+ *
74
+ * @param {{stateDir: string, approvalSha: string, trustedOperatorKeyIds?: string[], now?: number,
75
+ * repo: string, baseCommit: string, targetRef: string, expectedOld: string, integrationHead: string,
76
+ * planSha?: string|null}} input
77
+ */
78
+ export function loadMergeApproval(input) {
79
+ if (typeof input?.approvalSha !== 'string' || !HEX64.test(input.approvalSha)) return { ok: false, code: 'MERGE_APPROVAL_ID_INVALID' };
80
+ const path = join(input.stateDir, 'joint-dev', 'merge-approvals', input.approvalSha + '.json');
81
+ if (!existsSync(path)) return { ok: false, code: 'MERGE_APPROVAL_NOT_FOUND' };
82
+ let record = null;
83
+ try { record = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); } catch { return { ok: false, code: 'MERGE_APPROVAL_UNREADABLE' }; }
84
+ if (record?.type !== 'joint.merge') return { ok: false, code: 'MERGE_APPROVAL_TYPE_INVALID' };
85
+ if (record.approval_sha256 !== sha256(JSON.stringify(bodyOf(record)))) return { ok: false, code: 'MERGE_APPROVAL_TAMPERED' };
86
+ const pem = record?.operator_key?.public_key_pem;
87
+ if (typeof pem !== 'string' || record?.operator_key?.key_id !== keyIdOf(pem)) return { ok: false, code: 'MERGE_APPROVAL_KEY_MISMATCH' };
88
+ const trusted = Array.isArray(input.trustedOperatorKeyIds) ? input.trustedOperatorKeyIds : null;
89
+ if (trusted !== null && !trusted.includes(record.operator_key.key_id)) return { ok: false, code: 'MERGE_APPROVAL_KEY_UNTRUSTED' };
90
+ if (verifyApprovalPayload(record, record.sig, pem) !== true) return { ok: false, code: 'MERGE_APPROVAL_SIGNATURE_INVALID' };
91
+ const now = Number.isInteger(input.now) ? input.now : Date.now();
92
+ if (Date.parse(record.expires_at) <= now) return { ok: false, code: 'MERGE_APPROVAL_EXPIRED' };
93
+ for (const [field, expected] of [['repo', input.repo], ['base_commit', input.baseCommit], ['target_ref', input.targetRef], ['expected_old', input.expectedOld], ['integration_head', input.integrationHead]]) {
94
+ if (record[field] !== expected) return { ok: false, code: 'MERGE_APPROVAL_MISMATCH', detail: { field, expected, actual: record[field] } };
95
+ }
96
+ if (input.planSha !== undefined && input.planSha !== null && record.plan_sha256 !== input.planSha) {
97
+ return { ok: false, code: 'MERGE_APPROVAL_MISMATCH', detail: { field: 'plan_sha256', expected: input.planSha, actual: record.plan_sha256 } };
98
+ }
99
+ return { ok: true, approval: record, strength: 'signature' };
100
+ }
101
+
102
+ /** One-shot consumption: the marker is created with O_EXCL, so a second apply cannot reuse the signature. */
103
+ export function consumeMergeApproval({ stateDir, approvalSha }) {
104
+ if (typeof approvalSha !== 'string' || !HEX64.test(approvalSha)) return { ok: false, code: 'MERGE_APPROVAL_ID_INVALID' };
105
+ const dir = join(stateDir, 'joint-dev', 'merge-consumed');
106
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
107
+ const marker = join(dir, approvalSha);
108
+ try {
109
+ writeFileSync(marker, new Date().toISOString() + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
110
+ return { ok: true };
111
+ } catch (error) {
112
+ if (error?.code === 'EEXIST') return { ok: false, code: 'MERGE_APPROVAL_ALREADY_USED' };
113
+ return { ok: false, code: 'MERGE_APPROVAL_CONSUME_FAILED', detail: error instanceof Error ? error.message : String(error) };
114
+ }
115
+ }