@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.ts CHANGED
@@ -8,7 +8,9 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { pendingInboxCount } from "./inbox-count.js";
10
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
11
- import { canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId } from "./network-scope.js";
11
+ import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
12
+ import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
13
+ import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
12
14
  import { handleRequirementsRequest } from "./requirements.js";
13
15
  import {
14
16
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
@@ -129,7 +131,42 @@ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }
129
131
  return { ok: true, node_id: id };
130
132
  }
131
133
 
134
+ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[], _userId: string): void {
135
+ const denied = (tool: string) => ({
136
+ content: [{
137
+ type: "text" as const,
138
+ text: JSON.stringify({
139
+ ok: false,
140
+ error: "agent_access_restricted",
141
+ message: `${tool} is not available to restricted members in this network; pass network_id of a network where you are not restricted, or ask a network admin for access`,
142
+ }),
143
+ }],
144
+ });
145
+ const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
146
+ if (!RESTRICTED_MEMBER_TOOLS.has(name)) {
147
+ const args = callArgs[0];
148
+ const raw = args && typeof args === "object" ? (args as Record<string, unknown>).network_id : undefined;
149
+ const net = typeof raw === "string" && raw.trim() ? raw.trim() : null;
150
+ if (!net || restrictedNets.includes(net)) return denied(name);
151
+ }
152
+ return handler(...callArgs);
153
+ };
154
+ const anyServer = server as any;
155
+ for (const method of ["tool", "registerTool"] as const) {
156
+ const original = anyServer[method].bind(server);
157
+ anyServer[method] = (...regArgs: any[]) => {
158
+ const last = regArgs.length - 1;
159
+ if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
160
+ return original(...regArgs);
161
+ };
162
+ }
163
+ }
164
+
132
165
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
166
+ // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
167
+ // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
168
+ const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
169
+ if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
133
170
  // Default from_session for outbound tools — extracted from the calling
134
171
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
135
172
  // an agent's send_task call always claimed from='hub' and peer agents
@@ -176,6 +213,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
176
213
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
177
214
  return canRestWriteNetwork(mcpAuthCtx, netId, false);
178
215
  };
216
+ // 人类侧写入(私信):不看 Agent 授权,受限成员也可以。
217
+ const canWriteHuman = (effectiveNetworkId?: string | null): boolean =>
218
+ canRestWriteNetworkAsHuman(mcpAuthCtx, enforceNetworkId ?? effectiveNetworkId ?? null, false);
219
+ // 给 Agent 发任务 / 消息的准入。不受限:原判据。受限网络里:目标必须是授权且 can_message 的 Agent,
220
+ // 发件人固定为自己的用户名;目标不存在与没授权返回同一个错误,不留 alias 探测差异。
221
+ const agentSendDenied = (effectiveNetworkId: string | null, alias: string, clientFrom: string | undefined, action: "send_task" | "write", meta?: unknown) => {
222
+ if (!effectiveNetworkId || !restrictedNets.includes(effectiveNetworkId)) {
223
+ return canWrite(effectiveNetworkId) ? null : writeDeniedReply(effectiveNetworkId, action);
224
+ }
225
+ if (!canWriteHuman(effectiveNetworkId)) return writeDeniedReply(effectiveNetworkId, action);
226
+ const requestedFrom = clientFrom?.trim();
227
+ if (requestedFrom && requestedFrom !== callerAlias) {
228
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
229
+ }
230
+ const targetAlias = resolveCanonicalAlias(effectiveNetworkId, alias).alias;
231
+ const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, effectiveNetworkId);
232
+ if (!session || !canMessageAgent(enforceUserId!, effectiveNetworkId, { alias: targetAlias, nodeId: session.node_id })) {
233
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }) }] };
234
+ }
235
+ const rawAttachments = meta && typeof meta === "object" ? (meta as { attachments?: unknown }).attachments : undefined;
236
+ if (restrictedMemberAttachmentsDenied(enforceUserId!, callerAlias ?? "", effectiveNetworkId, rawAttachments)) {
237
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "attachment_not_accessible" }) }] };
238
+ }
239
+ return null;
240
+ };
179
241
 
180
242
  // #517: name the REAL cause. The old catch-all blamed permissions for
181
243
  // what was actually an unresolvable network, sending operators down the
@@ -379,24 +441,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
379
441
  }
380
442
  };
381
443
 
382
- type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
444
+ type ReadScope = RestNetworkScope;
383
445
 
384
446
  // Delegates to the shared membership query (network-scope.ts) — was a
385
447
  // byte-for-byte duplicate of getUserNetworkIds before #517.
386
448
  const getReadableNetworkIds = (): string[] =>
387
449
  enforceUserId ? getUserNetworkIds(enforceUserId) : [];
388
450
 
451
+ // 受限网络挂在 scope.agentRestriction 上:addReadScope(= addNetworkScope)对它们 fail-closed,
452
+ // 逐条审过的工具改用 addAgentNetworkScope / addOwnTrafficScope 按授权放行。
453
+ const withRestriction = (scope: ReadScope): ReadScope => {
454
+ if (!restrictedNets.length || !enforceUserId) return scope;
455
+ const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
456
+ const restricted = restrictedNets.filter((id) => inScope.includes(id));
457
+ return restricted.length ? { ...scope, agentRestriction: { userId: enforceUserId, username: callerAlias ?? "", networkIds: restricted } } : scope;
458
+ };
389
459
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
390
460
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
391
461
  if (enforceNetworkId) {
392
462
  const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
393
- return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
463
+ return role ? { networkId: enforceNetworkId, networkIds: null } : { networkId: null, networkIds: [], denied: "not a member of token network" };
394
464
  }
395
465
  if (clientNetId) {
396
466
  const role = getUserNetworkRole(enforceUserId, clientNetId);
397
- return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
467
+ return role ? withRestriction({ networkId: clientNetId, networkIds: null }) : { networkId: null, networkIds: [], denied: "access denied to requested network" };
398
468
  }
399
- return { networkId: null, networkIds: getReadableNetworkIds() };
469
+ return withRestriction({ networkId: null, networkIds: getReadableNetworkIds() });
400
470
  };
401
471
 
402
472
  // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
@@ -407,20 +477,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
407
477
  // Dispatch (POST /api/task + /api/broadcast). Per
408
478
  // per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
409
479
 
410
- const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
411
- if (scope.networkId) {
412
- sql += ` AND ${column} = ?${params.length + 1}`;
413
- params.push(scope.networkId);
414
- return sql;
415
- }
416
- if (scope.networkIds) {
417
- if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
418
- const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
419
- sql += ` AND ${column} IN (${placeholders})`;
420
- params.push(...scope.networkIds);
421
- }
422
- return sql;
423
- };
480
+ // 默认读作用域:对受限网络 fail-closed(见 network-scope.ts addNetworkScope)。
481
+ const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string =>
482
+ addNetworkScope(sql, params, scope, column);
424
483
 
425
484
  type DeliveryTarget =
426
485
  | { state: "online"; alias: string; session: any }
@@ -1433,7 +1492,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1433
1492
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
1434
1493
  let sql = "SELECT * FROM sessions WHERE 1=1";
1435
1494
  const params: any[] = [];
1436
- sql = addReadScope(sql, params, readScope);
1495
+ sql = addAgentNetworkScope(sql, params, readScope, { alias: "alias", nodeId: "node_id" });
1437
1496
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
1438
1497
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1439
1498
  const aliasFilter = parseAliasFilter(filter_alias);
@@ -1454,7 +1513,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1454
1513
  // under existing callers.
1455
1514
  const summaryParams: any[] = [];
1456
1515
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
1457
- summarySql = addReadScope(summarySql, summaryParams, readScope);
1516
+ summarySql = addAgentNetworkScope(summarySql, summaryParams, readScope, { alias: "alias", nodeId: "node_id" });
1458
1517
  summarySql += " GROUP BY status";
1459
1518
  const summary = db.all(summarySql, ...summaryParams);
1460
1519
 
@@ -1488,7 +1547,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1488
1547
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
1489
1548
  const sessionParams: any[] = [alias];
1490
1549
  let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
1491
- sessionSql = addReadScope(sessionSql, sessionParams, readScope);
1550
+ sessionSql = addAgentNetworkScope(sessionSql, sessionParams, readScope, { alias: "alias", nodeId: "node_id" });
1492
1551
  const session = db.get(sessionSql, ...sessionParams);
1493
1552
 
1494
1553
  const pendingParams: any[] = [alias];
@@ -1543,8 +1602,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1543
1602
  // existence + ownership of foreign parents via the difference in
1544
1603
  // error codes. Run canWrite first so a viewer ALWAYS gets the
1545
1604
  // same `permission_denied` regardless of parent state.
1546
- if (!canWrite(effectiveNetId)) {
1547
- return writeDeniedReply(effectiveNetId, "send_task");
1605
+ {
1606
+ const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "send_task", meta);
1607
+ if (sendDenied) return sendDenied;
1548
1608
  }
1549
1609
 
1550
1610
  // Resolve parent_task_id: explicit > inferred (caller's most recent
@@ -1784,7 +1844,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1784
1844
  },
1785
1845
  async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1786
1846
  const effectiveNetId = getNetworkId(netId);
1787
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1847
+ { const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "write"); if (sendDenied) return sendDenied; }
1788
1848
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
1789
1849
  const targetAlias = canonical.alias;
1790
1850
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
@@ -2292,7 +2352,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2292
2352
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
2293
2353
  const params: any[] = [task_id];
2294
2354
  let sql = "SELECT * FROM tasks WHERE task_id = ?1";
2295
- sql = addReadScope(sql, params, readScope);
2355
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2296
2356
  const task = db.get<any>(sql, ...params);
2297
2357
  return {
2298
2358
  content: [{
@@ -2361,7 +2421,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2361
2421
  }
2362
2422
  let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
2363
2423
  const params: any[] = [];
2364
- sql = addReadScope(sql, params, readScope);
2424
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2365
2425
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
2366
2426
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
2367
2427
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
@@ -2377,7 +2437,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2377
2437
  // Stats
2378
2438
  const statsParams: any[] = [];
2379
2439
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
2380
- statsSql = addReadScope(statsSql, statsParams, readScope);
2440
+ statsSql = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2381
2441
  statsSql += " GROUP BY status";
2382
2442
  const stats = db.all(statsSql, ...statsParams);
2383
2443
 
@@ -2511,7 +2571,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2511
2571
  if (fromMismatch) return fromMismatch;
2512
2572
  const from_session = defaultFrom(_fromIn);
2513
2573
  const effectiveNetId = getNetworkId(netId);
2514
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2574
+ // 人类 ↔ 人类私信:受限成员也能发(canWriteHuman 不看 Agent 授权),但发件人固定为自己的用户名。
2575
+ if (!canWriteHuman(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2576
+ if (effectiveNetId && restrictedNets.includes(effectiveNetId) && _fromIn?.trim() && _fromIn.trim() !== callerAlias) {
2577
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
2578
+ }
2515
2579
  if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2516
2580
 
2517
2581
  if (!to_user_id && !to_username) {
@@ -2592,8 +2656,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2592
2656
  // OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
2593
2657
  db.run(
2594
2658
  `INSERT INTO user_inbox
2595
- (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
2596
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2659
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id)
2660
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
2597
2661
  ON CONFLICT(message_id) DO NOTHING`,
2598
2662
  [
2599
2663
  messageId,
@@ -2610,6 +2674,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2610
2674
  // 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
2611
2675
  // 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
2612
2676
  meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
2677
+ // 多用户私信:只有人(用户令牌)发的才记发信人 user_id;节点令牌发的是 Agent 消息。
2678
+ !callerTokenIsNetwork && enforceUserId ? enforceUserId : null,
2613
2679
  ],
2614
2680
  );
2615
2681
  });
@@ -5697,6 +5763,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5697
5763
  const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
5698
5764
  const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
5699
5765
  const reqFields = {
5766
+ tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
5700
5767
  name: z.string().min(1).max(80).optional(),
5701
5768
  priority: z.enum(["high", "normal", "low"]).optional(),
5702
5769
  column: z.enum(["pool", "doing", "done"]).optional(),