@sleep2agi/commhub-server 0.9.0-preview.91 → 0.9.0-preview.92
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/package.json +1 -1
- package/src/node-permissions-http.test.ts +258 -0
- package/src/node-permissions.ts +242 -0
- package/src/requirements.ts +76 -4
- package/src/server.ts +98 -6
- package/src/task-access.ts +41 -3
- package/src/tools.ts +60 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.92",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// RFC-041 第一阶段(#487)—— 节点自己的权限:先记录、后执行。全部经真实 HTTP 入口(bootServer + fetch REST / /mcp / SSE)。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. log(默认):正常模式的节点「本来会被拒」的写照常放行,只按 (节点, 路由, 原因, 小时) 合并记一行。
|
|
5
|
+
// 2. 显式模式不看开关、立刻生效:只读写什么都拒、回复 / 心跳照常;受限只看只改派给它的卡,广播 / 订阅别人的推送都拒。
|
|
6
|
+
// 3. enforce:同一组请求按规则拒(node_permission_denied + reason + hint),含只有人能做的事(human_only)。
|
|
7
|
+
// 4. off:不判也不记(显式模式仍生效)。
|
|
8
|
+
// 5. 改模式:节点主人 / 网络 owner、admin 能;别的成员 403、节点令牌 403、不在网络里 404。
|
|
9
|
+
// 6. 报表:owner/admin 看到过去 7 天每个节点「本来会拦」的计数;成员 / 节点令牌 403。
|
|
10
|
+
// 7. 每个注册的 MCP 工具都归了类(新工具忘了归类会红)。8. 记录有上限、会清理。
|
|
11
|
+
//
|
|
12
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-permissions-http.test.ts
|
|
13
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
14
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
19
|
+
import { replaceTaskGrants } from "./task-access.js";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { NODE_TOOL_CLASS, pruneNodePermissionLog, __resetNodePermissionLogStateForTest } from "./node-permissions.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("node-permissions requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const PW = "NodePerm123!";
|
|
29
|
+
let server: any, base = "", net = "";
|
|
30
|
+
const user: Record<string, { id: string; token: string }> = {};
|
|
31
|
+
const node: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
|
|
32
|
+
const card: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
35
|
+
const r = await fetch(`${base}${path}`, {
|
|
36
|
+
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
37
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
const text = await r.text();
|
|
40
|
+
let json: any = null; try { json = JSON.parse(text); } catch {}
|
|
41
|
+
return { status: r.status, body: json, text };
|
|
42
|
+
}
|
|
43
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
44
|
+
const r = await fetch(`${base}/mcp`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
47
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
48
|
+
});
|
|
49
|
+
const text = await r.text();
|
|
50
|
+
const data = text.split("\n").find((l) => l.startsWith("data: "));
|
|
51
|
+
const msg = JSON.parse(data ? data.slice(6) : text);
|
|
52
|
+
const inner = msg.result?.content?.[0]?.text;
|
|
53
|
+
try { return JSON.parse(inner); } catch { return { raw: inner ?? msg }; }
|
|
54
|
+
}
|
|
55
|
+
const sse = async (token: string, path: string) => {
|
|
56
|
+
const ac = new AbortController();
|
|
57
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` }, signal: ac.signal });
|
|
58
|
+
const status = r.status;
|
|
59
|
+
const body = status === 200 ? null : await r.json().catch(() => null);
|
|
60
|
+
ac.abort();
|
|
61
|
+
return { status, body };
|
|
62
|
+
};
|
|
63
|
+
const logRows = (nodeId: string) => db.all<{ route: string; reason: string; hits: number | string }>(
|
|
64
|
+
"SELECT route, reason, hits FROM node_permission_log WHERE network_id = ?1 AND node_id = ?2 ORDER BY route, reason", net, nodeId);
|
|
65
|
+
const hitsOf = (nodeId: string, route: string, reason: string) =>
|
|
66
|
+
Number(logRows(nodeId).find((r) => r.route === route && r.reason === reason)?.hits ?? 0);
|
|
67
|
+
const attrsRev = (nodeId: string) => Number(db.get<{ r: number | string | null }>("SELECT attrs_revision AS r FROM nodes WHERE node_id = ?1", nodeId)?.r ?? 0);
|
|
68
|
+
const setMode = (nodeKey: string, mode: string) => db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node[nodeKey].id]);
|
|
69
|
+
const newCard = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
70
|
+
const r = await call(user.boss.token, "POST", "/api/requirements", { name, network_id: net, ...extra });
|
|
71
|
+
expect(r.status).toBe(201);
|
|
72
|
+
return r.body.requirement.id as string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
const boss = register(`np_boss_${stamp}`, PW); net = boss.network_id!; user.boss = { id: boss.user!.user_id, token: boss.token! };
|
|
77
|
+
for (const k of ["sco", "mem", "out"]) {
|
|
78
|
+
const u = register(`np_${k}_${stamp}`, PW); user[k] = { id: u.user!.user_id, token: u.token! };
|
|
79
|
+
if (k !== "out") expect(addNetworkMember(net, user[k].id, "member", user.boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
// sco:只看相关任务的成员(task_access=scoped)—— 他的节点在正常模式下会碰到「主人看不见」的卡。
|
|
82
|
+
expect(replaceTaskGrants({ networkId: net, userId: user.sco.id, taskAccess: "scoped", actorUserId: user.boss.id }).ok).toBe(true);
|
|
83
|
+
for (const [key, owner] of [["nN", "boss"], ["nR", "boss"], ["nRO", "boss"], ["nS", "sco"], ["other", "boss"]] as const) {
|
|
84
|
+
const alias = `${key.toLowerCase()}-${stamp}`, id = `n_np_${key.toLowerCase()}_${stamp}`;
|
|
85
|
+
const t = createNetworkTokenForNode(user[owner].id, net, alias, id);
|
|
86
|
+
expect(t.ok).toBe(true);
|
|
87
|
+
node[key] = { id, alias, token: t.token!, tokenId: t.token_id! };
|
|
88
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, net]);
|
|
89
|
+
}
|
|
90
|
+
const mod: any = await import("./server.js");
|
|
91
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
92
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
93
|
+
card.assigned = await newCard("assigned to nR", { agent_owner: { kind: "node", id: node.nR.id } });
|
|
94
|
+
card.child = await newCard("child of assigned", { parent_id: card.assigned });
|
|
95
|
+
card.boss = await newCard("boss only");
|
|
96
|
+
card.scoPart = await newCard("sco participates", { participants: [{ kind: "user", id: user.sco.id }] });
|
|
97
|
+
setMode("nR", "restricted");
|
|
98
|
+
setMode("nRO", "readonly");
|
|
99
|
+
}, 60_000);
|
|
100
|
+
afterEach(() => { delete process.env.COMMHUB_NODE_PERMISSIONS; });
|
|
101
|
+
afterAll(() => { server?.stop?.(true); });
|
|
102
|
+
|
|
103
|
+
describe("log (default): never blocks a normal node, records would-be denials", () => {
|
|
104
|
+
test("a write beyond the owner's visibility goes through and is logged once per hour, hits merged", async () => {
|
|
105
|
+
const route = "PATCH /api/requirements/:id";
|
|
106
|
+
const before = hitsOf(node.nS.id, route, "beyond_owner_visibility");
|
|
107
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "via scoped owner's node" })).status).toBe(200);
|
|
108
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "again" })).status).toBe(200);
|
|
109
|
+
expect(hitsOf(node.nS.id, route, "beyond_owner_visibility")).toBe(before + 2);
|
|
110
|
+
expect(logRows(node.nS.id).filter((r) => r.route === route).length).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a list read that includes cards the owner cannot see is returned in full and logged", async () => {
|
|
114
|
+
const r = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
expect(r.body.requirements.map((x: any) => x.id)).toContain(card.boss);
|
|
117
|
+
expect(hitsOf(node.nS.id, "GET /api/requirements", "beyond_owner_visibility")).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("human-only actions are logged, not blocked by this layer", async () => {
|
|
121
|
+
const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "renamed by a node", base_attrs_revision: attrsRev(node.other.id) });
|
|
122
|
+
expect(r.status).toBe(200);
|
|
123
|
+
expect(hitsOf(node.nN.id, "PUT /api/nodes/:id/attrs", "human_only")).toBe(1);
|
|
124
|
+
const p = await mcp(node.nN.token, "projects_create", { network_id: net, name: `p-${stamp}` });
|
|
125
|
+
expect(p.error).not.toBe("node_permission_denied");
|
|
126
|
+
expect(hitsOf(node.nN.id, "mcp:projects_create", "human_only")).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an all-access owner's normal node does its usual work with zero log rows", async () => {
|
|
130
|
+
expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "hello" })).ok).toBe(true);
|
|
131
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { column: "doing" })).status).toBe(200);
|
|
132
|
+
expect(logRows(node.nN.id).filter((r) => r.reason !== "human_only")).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("explicit modes are enforced immediately (even under log)", () => {
|
|
137
|
+
test("read-only: writes and dispatch refused with a hint; heartbeat, replies and reads still work", async () => {
|
|
138
|
+
const w = await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "nope" });
|
|
139
|
+
expect(w.status).toBe(403);
|
|
140
|
+
expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
|
|
141
|
+
expect(w.body.hint).toContain("read-only");
|
|
142
|
+
expect((await mcp(node.nRO.token, "send_task", { alias: node.other.alias, task: "x" })).reason).toBe("mode_readonly");
|
|
143
|
+
expect((await call(node.nRO.token, "POST", "/api/task", { alias: node.other.alias, task: "x", network_id: net })).body.reason).toBe("mode_readonly");
|
|
144
|
+
expect((await mcp(node.nRO.token, "report_status", { resume_id: `r_${node.nRO.id}`, alias: node.nRO.alias, status: "idle" })).ok).toBe(true);
|
|
145
|
+
const t = await mcp(node.nN.token, "send_task", { alias: node.nRO.alias, task: "please answer" });
|
|
146
|
+
const reply = await mcp(node.nRO.token, "send_reply", { in_reply_to: t.task_id ?? t.message_id, text: "done", status: "replied" });
|
|
147
|
+
expect(reply.ok).not.toBe(false);
|
|
148
|
+
expect((await call(node.nRO.token, "GET", `/api/requirements?network_id=${net}`)).status).toBe(200);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("restricted: sees and edits only cards assigned to it (and their subtasks)", async () => {
|
|
152
|
+
const list = await call(node.nR.token, "GET", `/api/requirements?network_id=${net}`);
|
|
153
|
+
expect(list.body.requirements.map((x: any) => x.id).sort()).toEqual([card.assigned, card.child].sort());
|
|
154
|
+
expect((await call(node.nR.token, "GET", `/api/requirements/${card.boss}`)).status).toBe(404);
|
|
155
|
+
expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.assigned}`, { column: "doing" })).status).toBe(200);
|
|
156
|
+
expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.child}`, { column: "doing" })).status).toBe(200);
|
|
157
|
+
expect((await call(node.nR.token, "POST", `/api/requirements/${card.assigned}/comments`, { text: "progress" })).status).toBe(201);
|
|
158
|
+
const mine = await call(node.nR.token, "POST", "/api/requirements", { name: "mine", network_id: net, agent_owner: { kind: "node", id: node.nR.id } });
|
|
159
|
+
expect(mine.status).toBe(201);
|
|
160
|
+
const notMine = await call(node.nR.token, "POST", "/api/requirements", { name: "not mine", network_id: net });
|
|
161
|
+
expect(notMine.status).toBe(403);
|
|
162
|
+
expect(notMine.body.reason).toBe("mode_restricted_not_assigned");
|
|
163
|
+
const viaMcp = await mcp(node.nR.token, "requirements_list", { network_id: net });
|
|
164
|
+
expect((viaMcp.requirements ?? []).map((x: any) => x.id)).not.toContain(card.boss);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("restricted: no broadcast, no network stream, no other session's stream; its own stream works", async () => {
|
|
168
|
+
expect((await mcp(node.nR.token, "broadcast", { message: "hi all" })).reason).toBe("mode_restricted_not_assigned");
|
|
169
|
+
expect((await call(node.nR.token, "POST", "/api/broadcast", { message: "hi", network_id: net })).body.reason).toBe("mode_restricted_not_assigned");
|
|
170
|
+
expect((await sse(node.nR.token, `/events/network/${net}`)).status).toBe(403);
|
|
171
|
+
expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.other.alias)}?network_id=${net}`)).status).toBe(403);
|
|
172
|
+
expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.nR.alias)}?network_id=${net}`)).status).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("enforce", () => {
|
|
177
|
+
test("the same requests that were only logged are refused with node_permission_denied", async () => {
|
|
178
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
179
|
+
// 主人看不见的卡:与不存在同一个 404(按主人的可见范围读)。
|
|
180
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "now refused" })).status).toBe(404);
|
|
181
|
+
// 主人看得见(他是参与人)但改不了的卡:node_permission_denied。
|
|
182
|
+
const w = await call(node.nS.token, "PATCH", `/api/requirements/${card.scoPart}`, { description: "now refused" });
|
|
183
|
+
expect(w.status).toBe(403);
|
|
184
|
+
expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "beyond_owner_visibility" });
|
|
185
|
+
const list = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
|
|
186
|
+
expect(list.body.requirements.map((x: any) => x.id)).not.toContain(card.boss);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("human-only actions are denied (REST and MCP); a node can still write its own attrs", async () => {
|
|
190
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
191
|
+
const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "nope", base_attrs_revision: attrsRev(node.other.id) });
|
|
192
|
+
expect(r.status).toBe(403);
|
|
193
|
+
expect(r.body.reason).toBe("human_only");
|
|
194
|
+
expect((await mcp(node.nN.token, "projects_create", { network_id: net, name: `q-${stamp}` })).reason).toBe("human_only");
|
|
195
|
+
expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/attrs`, { display_name: "me", base_attrs_revision: attrsRev(node.nN.id) })).status).toBe(200);
|
|
196
|
+
expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "still fine" })).ok).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("off: nothing is evaluated or logged for normal nodes; explicit modes still apply", async () => {
|
|
200
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "off";
|
|
201
|
+
const before = JSON.stringify(logRows(node.nS.id));
|
|
202
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "off" })).status).toBe(200);
|
|
203
|
+
expect(JSON.stringify(logRows(node.nS.id))).toBe(before);
|
|
204
|
+
expect((await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "still read-only" })).status).toBe(403);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("PUT /api/nodes/:id/permission-mode", () => {
|
|
209
|
+
test("owner and network owner/admin can set it and it applies on the next request; others cannot", async () => {
|
|
210
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).body).toMatchObject({ ok: true, permission_mode: "readonly", previous: "normal" });
|
|
211
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).body.reason).toBe("mode_readonly");
|
|
212
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
|
|
213
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).status).toBe(200);
|
|
214
|
+
expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "restricted" })).status).toBe(200); // sco 是 nS 的主人
|
|
215
|
+
expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
|
|
216
|
+
expect((await call(user.mem.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(403);
|
|
217
|
+
expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).body.error).toBe("user_token_required");
|
|
218
|
+
expect((await call(user.out.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(404);
|
|
219
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "admin" })).status).toBe(400);
|
|
220
|
+
expect(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_permission_mode_changed' AND target_id = ?1", node.nN.id)!.n).toBeTruthy();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("GET /api/networks/:id/node-permission-report", () => {
|
|
225
|
+
test("owner sees would-have-blocked counts per node; members and node tokens are refused", async () => {
|
|
226
|
+
const r = await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report`);
|
|
227
|
+
expect(r.status).toBe(200);
|
|
228
|
+
const nS = r.body.nodes.find((n: any) => n.node_id === node.nS.id);
|
|
229
|
+
expect(nS.by_reason.beyond_owner_visibility).toBeGreaterThanOrEqual(2);
|
|
230
|
+
expect(nS.alias).toBe(node.nS.alias);
|
|
231
|
+
expect(r.body.total).toBe(r.body.nodes.reduce((n: number, e: any) => n + e.total, 0));
|
|
232
|
+
expect((await call(user.mem.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
|
|
233
|
+
expect((await call(node.nN.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
|
|
234
|
+
expect((await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report?since=${encodeURIComponent(new Date(Date.now() + 3_600_000).toISOString())}`)).body.total).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("bookkeeping", () => {
|
|
239
|
+
test("every MCP tool a node can call has a permission class", async () => {
|
|
240
|
+
const s = new McpServer({ name: "np", version: "1" });
|
|
241
|
+
registerTools(s, undefined, net, user.boss.id, node.nN.alias, true, node.nN.tokenId);
|
|
242
|
+
const client = new Client({ name: "np-client", version: "1" });
|
|
243
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
244
|
+
await s.connect(st); await client.connect(ct);
|
|
245
|
+
const names = (await client.listTools()).tools.map((t) => t.name);
|
|
246
|
+
await client.close(); await s.close();
|
|
247
|
+
expect(names.length).toBeGreaterThan(50);
|
|
248
|
+
expect(names.filter((n) => !NODE_TOOL_CLASS[n])).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("log rows older than the retention window are pruned", () => {
|
|
252
|
+
const old = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 13);
|
|
253
|
+
db.run("INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits) VALUES (?1, ?2, 'old', 'human_only', ?3, 5)", [net, node.nN.id, old]);
|
|
254
|
+
pruneNodePermissionLog();
|
|
255
|
+
__resetNodePermissionLogStateForTest();
|
|
256
|
+
expect(db.get("SELECT 1 AS hit FROM node_permission_log WHERE route = 'old'")).toBeFalsy();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// RFC-041 第一阶段(board #487):节点(Agent)自己的权限 —— 先记录、后执行。
|
|
2
|
+
//
|
|
3
|
+
// 节点的权限 = 主人的权限 ∩ 节点的模式(nodes.permission_mode:normal 正常 / readonly 只读 / restricted 受限)。
|
|
4
|
+
//
|
|
5
|
+
// • 模式是主人主动设的,**设了就立刻生效**(不看开关):只读的节点写什么都拒(mode_readonly);
|
|
6
|
+
// 受限的节点只碰派给它的任务、派活只给主人授权的 Agent(mode_restricted_not_assigned / agent_not_granted_to_owner)。
|
|
7
|
+
// • 正常模式下的收紧(超出主人可见范围 beyond_owner_visibility、主人没被授权的 Agent agent_not_granted_to_owner、
|
|
8
|
+
// 只有人能做的事 human_only)由 Hub 开关 COMMHUB_NODE_PERMISSIONS 决定:
|
|
9
|
+
// log(默认)—— 照常放行,只把「本来会被拒」按 (节点, 路由, 原因, 小时) 合并记进 node_permission_log;
|
|
10
|
+
// enforce —— 记录并拒绝(REST 403 / MCP 错误 node_permission_denied + reason + hint);
|
|
11
|
+
// off —— 不判也不记(显式模式仍然生效)。
|
|
12
|
+
//
|
|
13
|
+
// 判定的入口:REST(requirements.ts、/api/task、/api/broadcast、/api/nodes/:id 写)、MCP(tools.ts 按工具名包一层)、
|
|
14
|
+
// SSE(/events/:session、/events/network/:id)。只对节点令牌(ntok_)生效;用户令牌一条不变。
|
|
15
|
+
// 只增不改:nodes 多一列 permission_mode(默认 normal),新表 node_permission_log。回滚到上一版安全:旧代码不读它们。
|
|
16
|
+
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
19
|
+
|
|
20
|
+
export type NodePermissionMode = "normal" | "readonly" | "restricted";
|
|
21
|
+
export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
|
|
22
|
+
export type NodePermissionsFlag = "log" | "off" | "enforce";
|
|
23
|
+
export type NodePermissionReason =
|
|
24
|
+
| "beyond_owner_visibility"
|
|
25
|
+
| "agent_not_granted_to_owner"
|
|
26
|
+
| "mode_readonly"
|
|
27
|
+
| "mode_restricted_not_assigned"
|
|
28
|
+
| "human_only"
|
|
29
|
+
| "owner_unknown";
|
|
30
|
+
|
|
31
|
+
/** 显式模式的原因:不看开关,永远拒。 */
|
|
32
|
+
const MODE_REASONS: ReadonlySet<NodePermissionReason> = new Set(["mode_readonly", "mode_restricted_not_assigned"]);
|
|
33
|
+
|
|
34
|
+
try { db.exec("ALTER TABLE nodes ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'normal'"); } catch (e: any) {
|
|
35
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
36
|
+
}
|
|
37
|
+
db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS node_permission_log (
|
|
39
|
+
network_id TEXT NOT NULL,
|
|
40
|
+
node_id TEXT NOT NULL,
|
|
41
|
+
route TEXT NOT NULL,
|
|
42
|
+
reason TEXT NOT NULL,
|
|
43
|
+
hour TEXT NOT NULL,
|
|
44
|
+
hits INTEGER NOT NULL DEFAULT 1,
|
|
45
|
+
sample TEXT,
|
|
46
|
+
PRIMARY KEY (network_id, node_id, route, reason, hour)
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_node_permission_log_hour ON node_permission_log(network_id, hour);
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
/** 开关。未设 / 写错 → log(安全的默认:只记不拦)。每次现读:测试和运维改环境变量后无需重启模块。 */
|
|
52
|
+
export function nodePermissionsFlag(): NodePermissionsFlag {
|
|
53
|
+
const raw = (process.env.COMMHUB_NODE_PERMISSIONS ?? "").trim().toLowerCase();
|
|
54
|
+
return raw === "off" || raw === "enforce" ? raw : "log";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type NodeIdentity = {
|
|
58
|
+
networkId: string;
|
|
59
|
+
/** 绑定的节点;老的未绑定令牌 = null(按「正常」判,记录时用 token:<token_id>)。 */
|
|
60
|
+
nodeId: string | null;
|
|
61
|
+
logKey: string;
|
|
62
|
+
aliases: string[];
|
|
63
|
+
ownerUserId: string | null;
|
|
64
|
+
mode: NodePermissionMode;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function normalizeMode(raw: unknown): NodePermissionMode {
|
|
68
|
+
return raw === "readonly" || raw === "restricted" ? raw : "normal";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
|
|
72
|
+
export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
|
|
73
|
+
if (!tokenId || !networkId) return null;
|
|
74
|
+
const row = db.get<{ user_id: string | null; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
|
|
75
|
+
`SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
|
|
76
|
+
FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
|
|
77
|
+
WHERE t.token_id = ?1 AND t.network_id = ?2`,
|
|
78
|
+
tokenId, networkId,
|
|
79
|
+
);
|
|
80
|
+
if (!row) return null;
|
|
81
|
+
const aliases = new Set<string>();
|
|
82
|
+
if (row.alias) aliases.add(row.alias);
|
|
83
|
+
if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
|
|
84
|
+
return {
|
|
85
|
+
networkId,
|
|
86
|
+
nodeId: row.bound_node_id,
|
|
87
|
+
logKey: row.bound_node_id ?? `token:${tokenId}`,
|
|
88
|
+
aliases: [...aliases],
|
|
89
|
+
ownerUserId: row.owner_user_id || row.user_id || null,
|
|
90
|
+
mode: normalizeMode(row.permission_mode),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type Verdict = { reason: NodePermissionReason; sample?: string } | null;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 判一次:verdict=null → 放行;否则按原因和开关决定拒不拒,并记一行(off 时正常模式的原因不记)。
|
|
98
|
+
* 返回 true = 拒绝。
|
|
99
|
+
*/
|
|
100
|
+
export function nodeDecide(id: NodeIdentity, route: string, verdict: Verdict): boolean {
|
|
101
|
+
if (!verdict) return false;
|
|
102
|
+
const explicit = MODE_REASONS.has(verdict.reason);
|
|
103
|
+
const flag = nodePermissionsFlag();
|
|
104
|
+
if (!explicit && flag === "off") return false;
|
|
105
|
+
recordNodePermission(id, route, verdict.reason, verdict.sample);
|
|
106
|
+
return explicit || flag === "enforce";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const HINTS: Record<NodePermissionReason, string> = {
|
|
110
|
+
beyond_owner_visibility: "this node acts with its owner's permissions, and its owner cannot see or edit this; ask the owner for access, or assign the task to this node",
|
|
111
|
+
agent_not_granted_to_owner: "this Agent has not been granted to this node's owner; ask a network admin to grant it",
|
|
112
|
+
mode_readonly: "this node is in read-only mode; the node's owner can switch it to normal in the app",
|
|
113
|
+
mode_restricted_not_assigned: "this node is in restricted mode and only touches tasks assigned to it; assign the task to this node, or have the owner switch it to normal",
|
|
114
|
+
human_only: "only people can do this (members, projects, permissions, providers, secrets, other nodes); use a user token",
|
|
115
|
+
owner_unknown: "this node's owner is unknown; recreate the node token from the owner's account",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function nodePermissionDeniedBody(reason: NodePermissionReason, route: string): { ok: false; error: "node_permission_denied"; reason: NodePermissionReason; route: string; hint: string } {
|
|
119
|
+
return { ok: false, error: "node_permission_denied", reason, route, hint: HINTS[reason] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 记录:按 (网络, 节点, 路由, 原因, 小时) 合并;有上限、会清理 ──
|
|
123
|
+
export const LOG_RETENTION_DAYS = 30;
|
|
124
|
+
export const LOG_MAX_ROWS = 20_000;
|
|
125
|
+
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
126
|
+
let lastPrune = 0;
|
|
127
|
+
let logFull = false;
|
|
128
|
+
|
|
129
|
+
function hourOf(now = new Date()): string {
|
|
130
|
+
return now.toISOString().slice(0, 13); // 2026-10-03T08
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 清掉保留期外的行,并重新判断是否到了上限(到上限后只给已有的行加次数,不再新开行)。 */
|
|
134
|
+
export function pruneNodePermissionLog(now = new Date()): void {
|
|
135
|
+
const cutoff = hourOf(new Date(now.getTime() - LOG_RETENTION_DAYS * 86_400_000));
|
|
136
|
+
db.run("DELETE FROM node_permission_log WHERE hour < ?1", [cutoff]);
|
|
137
|
+
const n = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM node_permission_log")?.n ?? 0);
|
|
138
|
+
logFull = n >= LOG_MAX_ROWS;
|
|
139
|
+
lastPrune = now.getTime();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function recordNodePermission(id: NodeIdentity, route: string, reason: NodePermissionReason, sample?: string): void {
|
|
143
|
+
try {
|
|
144
|
+
const now = new Date();
|
|
145
|
+
if (now.getTime() - lastPrune > PRUNE_EVERY_MS) pruneNodePermissionLog(now);
|
|
146
|
+
const hour = hourOf(now);
|
|
147
|
+
const s = sample ? sample.slice(0, 200) : null;
|
|
148
|
+
const updated = db.run(
|
|
149
|
+
"UPDATE node_permission_log SET hits = hits + 1, sample = COALESCE(?6, sample) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND reason = ?4 AND hour = ?5",
|
|
150
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
151
|
+
);
|
|
152
|
+
if (Number(updated?.changes ?? 0) > 0 || logFull) return;
|
|
153
|
+
db.run(
|
|
154
|
+
`INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
|
|
155
|
+
ON CONFLICT(network_id, node_id, route, reason, hour) DO UPDATE SET hits = node_permission_log.hits + 1`,
|
|
156
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
157
|
+
);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// 记录失败绝不影响请求本身。
|
|
160
|
+
console.error(`[node-permissions] log failed: ${(e as Error).message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Test-only:让下一次记录先清理一遍,并可改上限判断。 */
|
|
165
|
+
export function __resetNodePermissionLogStateForTest(): void { lastPrune = 0; logFull = false; }
|
|
166
|
+
|
|
167
|
+
// ── 规则 ──
|
|
168
|
+
|
|
169
|
+
/** 派活 / 发消息 / 重派给 target。发给自己永远可以。 */
|
|
170
|
+
export function dispatchVerdict(id: NodeIdentity, target: AgentRef): Verdict {
|
|
171
|
+
if ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && id.nodeId && target.nodeId === id.nodeId)) return null;
|
|
172
|
+
const sample = target.alias ?? target.nodeId ?? undefined;
|
|
173
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
174
|
+
if (!id.ownerUserId) return id.mode === "restricted" ? { reason: "mode_restricted_not_assigned", sample } : { reason: "owner_unknown" };
|
|
175
|
+
if (!canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
176
|
+
// 受限是主人设的 → 必拒(模式原因);正常下是开关管的收紧。
|
|
177
|
+
return { reason: id.mode === "restricted" ? "mode_restricted_not_assigned" : "agent_not_granted_to_owner", sample };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 广播:只读 / 受限都不行(会发给没派给它、也未必授权的 Agent)。 */
|
|
183
|
+
export function broadcastVerdict(id: NodeIdentity): Verdict {
|
|
184
|
+
if (id.mode === "readonly") return { reason: "mode_readonly" };
|
|
185
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned" };
|
|
186
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 一般的写(管节点、提交技能、探测模型…):只读 / 受限拒;正常放行(由各自的既有规则管)。 */
|
|
191
|
+
export function writeVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
192
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
193
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample };
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 只有人能做的事。只读 / 受限下报模式原因(那是主人设的、必拒);正常下报 human_only。 */
|
|
198
|
+
export function humanOnlyVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
199
|
+
return writeVerdict(id, sample) ?? { reason: "human_only", sample };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 订阅别的会话 / 整个网络的推送流:受限不行;正常按主人能不能看到那个 Agent。 */
|
|
203
|
+
export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): Verdict {
|
|
204
|
+
if (target !== "network" && ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && target.nodeId === id.nodeId))) return null;
|
|
205
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample: target === "network" ? "network" : target.alias ?? undefined };
|
|
206
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
207
|
+
if (target !== "network" && !canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
208
|
+
return { reason: "beyond_owner_visibility", sample: target.alias ?? undefined };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
214
|
+
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "node_write" | "human_only";
|
|
215
|
+
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
216
|
+
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
217
|
+
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
218
|
+
mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
|
|
219
|
+
get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
|
|
220
|
+
get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
|
|
221
|
+
get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
|
|
222
|
+
list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
|
|
223
|
+
send_desktop_message: "always", // 发给人的私信,不是派活
|
|
224
|
+
// 读
|
|
225
|
+
get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
|
|
226
|
+
list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
|
|
227
|
+
get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
|
|
228
|
+
read_node_file: "read", tail_node_logs: "read",
|
|
229
|
+
// 派活
|
|
230
|
+
send_task: "dispatch", send_message: "dispatch", retry_task: "dispatch", reassign_task: "dispatch", cancel_task: "dispatch",
|
|
231
|
+
broadcast: "broadcast",
|
|
232
|
+
// 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
|
|
233
|
+
requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
|
|
234
|
+
requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
|
|
235
|
+
requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
|
|
236
|
+
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
237
|
+
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
|
|
238
|
+
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
239
|
+
// 只有人能做
|
|
240
|
+
upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
|
|
241
|
+
projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
|
|
242
|
+
};
|
package/src/requirements.ts
CHANGED
|
@@ -10,9 +10,10 @@ import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from ".
|
|
|
10
10
|
import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
|
|
11
11
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
12
12
|
import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
|
|
13
|
+
import { nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
13
14
|
import {
|
|
14
15
|
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, deletesAsHeadOnly, headOnlyEditScope, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
|
|
15
|
-
isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
16
|
+
cardAssignedToNode, isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
16
17
|
} from "./task-access.js";
|
|
17
18
|
import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
|
|
18
19
|
import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
|
|
@@ -93,11 +94,67 @@ function jsonError(error: string, status: number, ctx?: ErrorContext): Response
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
|
|
96
|
-
//
|
|
97
|
+
// Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
|
|
98
|
+
// 节点令牌(RFC-041,#487):受限模式 → 按主人的权限且只碰派给它的卡;执行开关为 enforce → 按主人的权限;
|
|
99
|
+
// 其余(默认 log 下的正常 / 只读节点)→ null,与以前一样(只读的写在 nodeTaskGate 里拒)。
|
|
97
100
|
function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
|
|
98
|
-
if (!ctx.auth || ctx.
|
|
101
|
+
if (!ctx.auth || ctx.isAdmin) return null;
|
|
102
|
+
if (ctx.isNodeToken) return nodeTaskCaller(ctx);
|
|
99
103
|
return { userId: ctx.auth.userId };
|
|
100
104
|
}
|
|
105
|
+
|
|
106
|
+
const nodeIdentities = new WeakMap<RequirementsRequestContext, NodeIdentity | null>();
|
|
107
|
+
function nodeIdent(ctx: RequirementsRequestContext): NodeIdentity | null {
|
|
108
|
+
if (!ctx.isNodeToken) return null;
|
|
109
|
+
if (!nodeIdentities.has(ctx)) nodeIdentities.set(ctx, nodeIdentity(ctx.auth?.tokenId, ctx.auth?.networkId));
|
|
110
|
+
return nodeIdentities.get(ctx) ?? null;
|
|
111
|
+
}
|
|
112
|
+
function nodeTaskCaller(ctx: RequirementsRequestContext): TaskCaller {
|
|
113
|
+
const id = nodeIdent(ctx);
|
|
114
|
+
if (!id?.ownerUserId) return null;
|
|
115
|
+
if (id.mode === "restricted") return { userId: id.ownerUserId, node: { id: id.logKey, only: true } };
|
|
116
|
+
if (nodePermissionsFlag() === "enforce") return { userId: id.ownerUserId, node: { id: id.logKey, only: false } };
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* RFC-041 —— 节点的一次任务写(建 / 改 / 勾检查项 / 评论 / upsert)先过这里。row=null 是新建(create 给出目标项目、
|
|
122
|
+
* 负责 Agent、父卡)。只读 → 必拒;受限 → 不是派给它的必拒;正常 → 主人看不见 / 改不了时按开关记录或拒。
|
|
123
|
+
*/
|
|
124
|
+
function nodeTaskGate(ctx: RequirementsRequestContext, route: string, row: Row | null, create?: { networkId: string; projectId: string | null; agentOwnerJson: string | null; parentId: string | null }): Response | null {
|
|
125
|
+
const id = nodeIdent(ctx);
|
|
126
|
+
if (!id) return null;
|
|
127
|
+
const sample = row ? `#${row.seq ?? "?"} ${row.requirement_id}` : create?.projectId ?? undefined;
|
|
128
|
+
let verdict: Verdict = null;
|
|
129
|
+
if (id.mode === "readonly") verdict = { reason: "mode_readonly", sample };
|
|
130
|
+
else if (id.mode === "restricted") {
|
|
131
|
+
const nodeRef = JSON.stringify({ kind: "node", id: id.logKey });
|
|
132
|
+
const parent = create?.parentId ? db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, create.parentId) : undefined;
|
|
133
|
+
const assigned = row ? cardAssignedToNode(row, id.logKey) : create?.agentOwnerJson === nodeRef || (!!parent && cardAssignedToNode(parent, id.logKey));
|
|
134
|
+
if (!assigned) verdict = { reason: "mode_restricted_not_assigned", sample };
|
|
135
|
+
} else if (!id.ownerUserId) verdict = { reason: "owner_unknown", sample };
|
|
136
|
+
else {
|
|
137
|
+
const asOwner: TaskCaller = { userId: id.ownerUserId, node: { id: id.logKey, only: false } };
|
|
138
|
+
const ok = row ? canSeeTask(asOwner, row) && canEditTask(asOwner, row) : canUseProject(asOwner, create!.networkId, create!.projectId);
|
|
139
|
+
if (!ok) verdict = { reason: "beyond_owner_visibility", sample };
|
|
140
|
+
}
|
|
141
|
+
return nodeDecide(id, route, verdict) ? Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 }) : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 正常模式的节点读到了主人看不见的卡:只记录(执行时 taskCaller 已按主人收窄,读不到)。主人不受任务范围限制时什么都不做。 */
|
|
145
|
+
function nodeReadNote(ctx: RequirementsRequestContext, route: string, row: Row | null): void {
|
|
146
|
+
const id = nodeIdent(ctx);
|
|
147
|
+
if (!id?.ownerUserId || id.mode === "restricted" || nodePermissionsFlag() !== "log" || !isTaskScoped(id.ownerUserId, id.networkId)) return;
|
|
148
|
+
const asOwner: TaskCaller = { userId: id.ownerUserId };
|
|
149
|
+
if (row) {
|
|
150
|
+
if (!canSeeTask(asOwner, row)) nodeDecide(id, route, { reason: "beyond_owner_visibility", sample: `#${row.seq ?? "?"} ${row.requirement_id}` });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const all = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", id.networkId)?.n ?? 0);
|
|
154
|
+
const params: unknown[] = [id.networkId];
|
|
155
|
+
const seen = Number(db.get<{ n: number | string }>(addTaskVisibilityScope("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", params, asOwner), ...params)?.n ?? 0);
|
|
156
|
+
if (all > seen) nodeDecide(id, route, { reason: "beyond_owner_visibility", sample: `list: ${all - seen} card(s) the owner cannot see` });
|
|
157
|
+
}
|
|
101
158
|
/**
|
|
102
159
|
* 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。
|
|
103
160
|
* field:参与人改了参与人不能改的字段 —— 错误码不变(旧 App 照旧认 task_read_only),多带 field + 一句中文 message。
|
|
@@ -825,6 +882,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
825
882
|
const createdAt = new Date().toISOString();
|
|
826
883
|
let people;
|
|
827
884
|
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
|
|
885
|
+
const nodeDenied = nodeTaskGate(ctx, "POST /api/requirements", null, { networkId, projectId, agentOwnerJson: people.agentOwnerJson, parentId });
|
|
886
|
+
if (nodeDenied) return nodeDenied;
|
|
828
887
|
const actor = JSON.stringify(actorOf(ctx));
|
|
829
888
|
try {
|
|
830
889
|
// 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
|
|
@@ -862,6 +921,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
862
921
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
|
|
863
922
|
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
864
923
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
924
|
+
const nodeDenied = nodeTaskGate(ctx, "PATCH /api/requirements/:id", row);
|
|
925
|
+
if (nodeDenied) return nodeDenied;
|
|
865
926
|
const caller = taskCaller(ctx);
|
|
866
927
|
if (!canEditTask(caller, row)) {
|
|
867
928
|
// 参与人:只放状态和检查项;别的字段点名拒绝。不是参与人 → 与今天一样。
|
|
@@ -1299,6 +1360,7 @@ function rememberList(key: string, entry: ListCacheEntry): void {
|
|
|
1299
1360
|
function listCacheAcl(ctx: RequirementsRequestContext): string | null {
|
|
1300
1361
|
const caller = taskCaller(ctx);
|
|
1301
1362
|
if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
|
|
1363
|
+
if (caller.node) return null; // 按节点收窄的列表不缓存(模式随时可改)
|
|
1302
1364
|
if (restrictedNetworkIds(caller.userId).length > 0) return null;
|
|
1303
1365
|
const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
|
|
1304
1366
|
const networks = members.map(m => String(m.network_id));
|
|
@@ -1417,6 +1479,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1417
1479
|
if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
|
|
1418
1480
|
// changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
|
|
1419
1481
|
const serverTime = new Date().toISOString();
|
|
1482
|
+
nodeReadNote(ctx, "GET /api/requirements", null);
|
|
1420
1483
|
const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
|
|
1421
1484
|
const acl = cacheKey ? listCacheAcl(ctx) : null;
|
|
1422
1485
|
const generation = requirementsGeneration;
|
|
@@ -1470,7 +1533,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1470
1533
|
return Response.json({ ...data, created: true }, { status: 201 });
|
|
1471
1534
|
}
|
|
1472
1535
|
const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
|
|
1473
|
-
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k)))
|
|
1536
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) {
|
|
1537
|
+
// 没有要改的字段 = 读回这张卡:受限节点只能读派给它的(与列表同一个可见范围)。
|
|
1538
|
+
if (!canSeeTask(taskCaller(ctx), existing)) return jsonError("upsert_not_allowed", 403);
|
|
1539
|
+
return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
|
|
1540
|
+
}
|
|
1474
1541
|
const res = patchRequirement(ctx, existing, patch);
|
|
1475
1542
|
if (res.status !== 200) return res;
|
|
1476
1543
|
const data = await res.json() as Record<string, unknown>;
|
|
@@ -1491,6 +1558,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1491
1558
|
const row = scopedRow(ctx, decodeURIComponent(commentMatch[1]));
|
|
1492
1559
|
if (row instanceof Response) return row;
|
|
1493
1560
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
1561
|
+
const nodeDenied = nodeTaskGate(ctx, "POST /api/requirements/:id/comments", row);
|
|
1562
|
+
if (nodeDenied) return nodeDenied;
|
|
1494
1563
|
const at = new Date().toISOString();
|
|
1495
1564
|
const event = db.transaction(() => {
|
|
1496
1565
|
recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "comment", field: null, old: null, new: { text } }], at);
|
|
@@ -1508,6 +1577,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1508
1577
|
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
1509
1578
|
if (row instanceof Response) return row;
|
|
1510
1579
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
1580
|
+
const nodeDenied = nodeTaskGate(ctx, "PATCH /api/requirements/:id/checklist/:item", row);
|
|
1581
|
+
if (nodeDenied) return nodeDenied;
|
|
1511
1582
|
if (!canEditTask(taskCaller(ctx), row) && !canParticipantEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
|
|
1512
1583
|
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
1513
1584
|
const items = storedChecklist(row.checklist_json);
|
|
@@ -1533,6 +1604,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1533
1604
|
const id = decodeURIComponent(match[1]);
|
|
1534
1605
|
if (req.method === "GET") {
|
|
1535
1606
|
const row = scopedRow(ctx, id);
|
|
1607
|
+
if (!(row instanceof Response)) nodeReadNote(ctx, "GET /api/requirements/:id", row);
|
|
1536
1608
|
return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
|
|
1537
1609
|
}
|
|
1538
1610
|
if (req.method === "DELETE") {
|
package/src/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./r
|
|
|
24
24
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
25
25
|
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
26
26
|
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
27
|
+
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
|
|
27
28
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
28
29
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
29
30
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
@@ -318,6 +319,18 @@ function countNetworkUploads(networkId: string): number {
|
|
|
318
319
|
return count;
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
// RFC-041(#487):节点令牌的一次 REST 写 / 订阅先按 node-permissions.ts 判。用户令牌 → null(不判)。
|
|
323
|
+
function restNodeDenied(req: Request, restAuth: { networkId: string | null; tokenId: string | null } | null, route: string, verdictOf: (id: NodeIdentity) => Verdict): Response | null {
|
|
324
|
+
if (!restAuth?.networkId) return null;
|
|
325
|
+
const id = nodeIdentity(restAuth.tokenId, restAuth.networkId);
|
|
326
|
+
if (!id) return null;
|
|
327
|
+
const verdict = verdictOf(id);
|
|
328
|
+
return nodeDecide(id, route, verdict) ? withCors(req, Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 })) : null;
|
|
329
|
+
}
|
|
330
|
+
/** 写别的节点 = 只有人能做;写自己 = 只看模式。 */
|
|
331
|
+
const nodeTargetVerdict = (targetNodeId: string) => (id: NodeIdentity): Verdict =>
|
|
332
|
+
id.nodeId && id.nodeId === targetNodeId ? writeVerdict(id, targetNodeId) : humanOnlyVerdict(id, targetNodeId);
|
|
333
|
+
|
|
321
334
|
function userTokenRequired(req: Request): Response {
|
|
322
335
|
return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
|
|
323
336
|
}
|
|
@@ -1007,11 +1020,9 @@ return Bun.serve({
|
|
|
1007
1020
|
// 1. legacy AUTH_TOKEN (master) → any network
|
|
1008
1021
|
// 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
|
|
1009
1022
|
// network. Membership is checked unconditionally — there is NO
|
|
1010
|
-
// token-bound-network shortcut
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
// bound to this network whose owner was removed must lose the
|
|
1014
|
-
// stream (审查修复 per 通信龙 #461 review, finding 1).
|
|
1023
|
+
// token-bound-network shortcut (审查修复 per 通信龙 #461 review,
|
|
1024
|
+
// finding 1). Since #488 removeNetworkMember also revokes the
|
|
1025
|
+
// user's tokens and resolveToken refuses a non-member's ntok.
|
|
1015
1026
|
const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
|
|
1016
1027
|
if (netEventsMatch && req.method === "GET") {
|
|
1017
1028
|
const authErr = requireAuth(req);
|
|
@@ -1028,6 +1039,8 @@ return Bun.serve({
|
|
|
1028
1039
|
if (!observerRole) {
|
|
1029
1040
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1030
1041
|
}
|
|
1042
|
+
const streamDenied = restNodeDenied(req, authCtx, "SSE /events/network/:id", (id) => streamVerdict(id, "network"));
|
|
1043
|
+
if (streamDenied) return streamDenied;
|
|
1031
1044
|
// 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
|
|
1032
1045
|
// 看不到网络里别人和各个 Agent 之间的流量。
|
|
1033
1046
|
if (isAgentRestricted(authCtx.userId, observedNetId)) {
|
|
@@ -1103,6 +1116,11 @@ return Bun.serve({
|
|
|
1103
1116
|
if (!session && authCtx.networkId !== scopedNetId) {
|
|
1104
1117
|
return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
1105
1118
|
}
|
|
1119
|
+
const streamDenied = restNodeDenied(req, authCtx, "SSE /events/:session", (id) => streamVerdict(id, {
|
|
1120
|
+
alias: sessionName,
|
|
1121
|
+
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
|
|
1122
|
+
}));
|
|
1123
|
+
if (streamDenied) return streamDenied;
|
|
1106
1124
|
return createSSEStream(sessionName, scopedNetId);
|
|
1107
1125
|
}
|
|
1108
1126
|
|
|
@@ -2271,6 +2289,67 @@ return Bun.serve({
|
|
|
2271
2289
|
// GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
|
|
2272
2290
|
// GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
|
|
2273
2291
|
// 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
|
|
2292
|
+
// ── RFC-041(#487):节点自己的权限 —— 改模式、看「本来会拦」的报表。只给人(用户令牌)。 ──
|
|
2293
|
+
const permModeMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/permission-mode$/);
|
|
2294
|
+
if (permModeMatch && req.method === "PUT") {
|
|
2295
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2296
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2297
|
+
let body: any;
|
|
2298
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 })); }
|
|
2299
|
+
const mode = body?.mode;
|
|
2300
|
+
if (!NODE_PERMISSION_MODES.includes(mode)) {
|
|
2301
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_permission_mode", message: `mode must be one of ${NODE_PERMISSION_MODES.join(", ")}` }, { status: 400 }));
|
|
2302
|
+
}
|
|
2303
|
+
const nodeRef = decodeURIComponent(permModeMatch[1]);
|
|
2304
|
+
const node = db.get<{ node_id: string; network_id: string | null; owner_user_id: string | null; permission_mode: string | null }>(
|
|
2305
|
+
"SELECT node_id, network_id, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1", nodeRef);
|
|
2306
|
+
const role = node?.network_id ? getUserNetworkRole(restAuth.userId, node.network_id) : null;
|
|
2307
|
+
// 看不见这个节点(不在它的网络里)与不存在同一个 404。
|
|
2308
|
+
if (!node || !node.network_id || (!role && !isAdmin)) return withCors(req, Response.json({ ok: false, error: "node_not_found" }, { status: 404 }));
|
|
2309
|
+
// 节点主人、网络 owner / admin、Hub 管理员能改;部门负责人和别的成员不能(RFC-041 §2.3 / §2.4)。
|
|
2310
|
+
if (!isAdmin && node.owner_user_id !== restAuth.userId && role !== "owner" && role !== "admin") {
|
|
2311
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "only the node's owner or a network owner/admin can change its permission mode" }, { status: 403 }));
|
|
2312
|
+
}
|
|
2313
|
+
const previous = (node.permission_mode || "normal") as NodePermissionMode;
|
|
2314
|
+
db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node.node_id]);
|
|
2315
|
+
logAudit(restAuth.userId, restAuth.username, "node_permission_mode_changed", "node", node.node_id, JSON.stringify({ from: previous, to: mode }), getClientIP(req), node.network_id);
|
|
2316
|
+
return withCors(req, Response.json({ ok: true, node_id: node.node_id, permission_mode: mode, previous }));
|
|
2317
|
+
}
|
|
2318
|
+
const permReportMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/node-permission-report$/);
|
|
2319
|
+
if (permReportMatch && req.method === "GET") {
|
|
2320
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2321
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2322
|
+
const networkId = decodeURIComponent(permReportMatch[1]);
|
|
2323
|
+
const role = getUserNetworkRole(restAuth.userId, networkId);
|
|
2324
|
+
if (!isAdmin && role !== "owner" && role !== "admin") {
|
|
2325
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network owner/admin only" }, { status: 403 }));
|
|
2326
|
+
}
|
|
2327
|
+
const sinceRaw = url.searchParams.get("since");
|
|
2328
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : Date.now() - 7 * 86_400_000;
|
|
2329
|
+
if (!Number.isFinite(sinceMs)) return withCors(req, Response.json({ ok: false, error: "invalid_since" }, { status: 400 }));
|
|
2330
|
+
const sinceHour = new Date(sinceMs).toISOString().slice(0, 13);
|
|
2331
|
+
const rows = db.all<{ node_id: string; route: string; reason: string; hits: number | string; sample: string | null; last_hour: string }>(
|
|
2332
|
+
`SELECT node_id, route, reason, SUM(hits) AS hits, MAX(sample) AS sample, MAX(hour) AS last_hour
|
|
2333
|
+
FROM node_permission_log WHERE network_id = ?1 AND hour >= ?2
|
|
2334
|
+
GROUP BY node_id, route, reason ORDER BY node_id, route, reason`,
|
|
2335
|
+
networkId, sinceHour);
|
|
2336
|
+
const byNode = new Map<string, { node_id: string; alias: string | null; permission_mode: string; total: number; by_reason: Record<string, number>; routes: Array<{ route: string; reason: string; hits: number; sample: string | null; last_hour: string }> }>();
|
|
2337
|
+
for (const r of rows) {
|
|
2338
|
+
let entry = byNode.get(r.node_id);
|
|
2339
|
+
if (!entry) {
|
|
2340
|
+
const n = db.get<{ alias: string | null; permission_mode: string | null }>("SELECT alias, permission_mode FROM nodes WHERE node_id = ?1", r.node_id);
|
|
2341
|
+
entry = { node_id: r.node_id, alias: n?.alias ?? null, permission_mode: n?.permission_mode || "normal", total: 0, by_reason: {}, routes: [] };
|
|
2342
|
+
byNode.set(r.node_id, entry);
|
|
2343
|
+
}
|
|
2344
|
+
const hits = Number(r.hits);
|
|
2345
|
+
entry.total += hits;
|
|
2346
|
+
entry.by_reason[r.reason] = (entry.by_reason[r.reason] ?? 0) + hits;
|
|
2347
|
+
entry.routes.push({ route: r.route, reason: r.reason, hits, sample: r.sample, last_hour: r.last_hour });
|
|
2348
|
+
}
|
|
2349
|
+
const nodes = [...byNode.values()].sort((a, b) => b.total - a.total || a.node_id.localeCompare(b.node_id));
|
|
2350
|
+
return withCors(req, Response.json({ ok: true, network_id: networkId, since: new Date(sinceMs).toISOString(), mode: nodePermissionsFlag(), total: nodes.reduce((n, e) => n + e.total, 0), nodes }));
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2274
2353
|
if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
|
|
2275
2354
|
if (!restAuth || !requestToken(req).startsWith("utok_")) {
|
|
2276
2355
|
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
@@ -3190,6 +3269,11 @@ return Bun.serve({
|
|
|
3190
3269
|
}
|
|
3191
3270
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
3192
3271
|
const targetAlias = canonical.alias;
|
|
3272
|
+
const nodeDenied = restNodeDenied(req, restAuth, "POST /api/task", (id) => dispatchVerdict(id, {
|
|
3273
|
+
alias: targetAlias,
|
|
3274
|
+
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
|
|
3275
|
+
}));
|
|
3276
|
+
if (nodeDenied) return nodeDenied;
|
|
3193
3277
|
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
3194
3278
|
if (target.state === "not_found") {
|
|
3195
3279
|
return withCors(req, Response.json({
|
|
@@ -3425,6 +3509,8 @@ return Bun.serve({
|
|
|
3425
3509
|
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
3426
3510
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3427
3511
|
}
|
|
3512
|
+
const broadcastDenied = restNodeDenied(req, restAuth, "POST /api/broadcast", broadcastVerdict);
|
|
3513
|
+
if (broadcastDenied) return broadcastDenied;
|
|
3428
3514
|
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
3429
3515
|
const params: any[] = [];
|
|
3430
3516
|
sql = addNetworkScope(sql, params, restScope);
|
|
@@ -3797,6 +3883,8 @@ return Bun.serve({
|
|
|
3797
3883
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3798
3884
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3799
3885
|
}
|
|
3886
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "DELETE /api/nodes/:id", nodeTargetVerdict(node.node_id));
|
|
3887
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
3800
3888
|
|
|
3801
3889
|
db.transaction(() => {
|
|
3802
3890
|
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
@@ -3907,6 +3995,8 @@ return Bun.serve({
|
|
|
3907
3995
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3908
3996
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3909
3997
|
}
|
|
3998
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/attrs", nodeTargetVerdict(node.node_id));
|
|
3999
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
3910
4000
|
|
|
3911
4001
|
// Optimistic lock BEFORE any narrowing work, so a losing writer never
|
|
3912
4002
|
// has side effects.
|
|
@@ -4006,6 +4096,8 @@ return Bun.serve({
|
|
|
4006
4096
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
4007
4097
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
4008
4098
|
}
|
|
4099
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/avatar", nodeTargetVerdict(node.node_id));
|
|
4100
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
4009
4101
|
|
|
4010
4102
|
db.run(
|
|
4011
4103
|
"UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
|
|
@@ -4271,7 +4363,7 @@ return Bun.serve({
|
|
|
4271
4363
|
config_path, channels, server, hostname,
|
|
4272
4364
|
network_id, created_at, updated_at,
|
|
4273
4365
|
config_snapshot, lifecycle_state, avatar_url,
|
|
4274
|
-
display_name, team, tags, attrs_revision
|
|
4366
|
+
display_name, team, tags, attrs_revision, permission_mode
|
|
4275
4367
|
FROM nodes WHERE 1=1`;
|
|
4276
4368
|
const params: any[] = [];
|
|
4277
4369
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
package/src/task-access.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function listProjectGrants(networkId: string, userId: string): ProjectGra
|
|
|
99
99
|
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
100
100
|
export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
|
|
101
101
|
|
|
102
|
-
type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null };
|
|
102
|
+
type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null; parent_id?: string | null };
|
|
103
103
|
const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
|
|
104
104
|
const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
|
|
105
105
|
const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
|
|
@@ -126,10 +126,33 @@ function headOf(userId: string, row: CardRow): boolean {
|
|
|
126
126
|
return !!cards && isDepartmentCard(cards, row);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* 调用者。userId = 按谁的任务权限判;node(RFC-041,#487)= 节点令牌按**主人**的权限判时带上节点:
|
|
131
|
+
* only=false(正常模式、执行开关打开)—— 主人能改的能改,另外「派给这个节点的」在主人看得见时也能改;
|
|
132
|
+
* only=true (受限模式)—— 只看、只改派给这个节点的卡(负责 Agent / 参与人 / 它建的,及这些卡的子任务)。
|
|
133
|
+
*/
|
|
134
|
+
export type TaskCaller = { userId: string; node?: { id: string; only: boolean } } | null;
|
|
135
|
+
|
|
136
|
+
export const nodeRefPattern = (nodeId: string) => `%${likeEscape(`"kind":"node","id":"${nodeId}"`)}%`;
|
|
137
|
+
const nodeRefIs = (json: string | null | undefined, nodeId: string) => !!json && json.includes(`"kind":"node","id":"${nodeId}"`);
|
|
138
|
+
const directlyAssigned = (row: Pick<CardRow, "agent_owner_json" | "participants_json" | "created_by_json">, nodeId: string) =>
|
|
139
|
+
nodeRefIs(row.agent_owner_json, nodeId) || nodeRefIs(row.participants_json, nodeId) || nodeRefIs(row.created_by_json, nodeId);
|
|
140
|
+
|
|
141
|
+
/** 这张卡派给了这个节点吗:负责 Agent / 参与人有它 / 它建的,或者父卡是。 */
|
|
142
|
+
export function cardAssignedToNode(row: CardRow, nodeId: string): boolean {
|
|
143
|
+
if (directlyAssigned(row, nodeId)) return true;
|
|
144
|
+
if (!row.parent_id) return false;
|
|
145
|
+
const parent = db.get<{ agent_owner_json: string | null; participants_json: string | null; created_by_json: string | null }>(
|
|
146
|
+
"SELECT agent_owner_json, participants_json, created_by_json FROM requirements WHERE requirement_id = ?1", row.parent_id);
|
|
147
|
+
return !!parent && directlyAssigned(parent, nodeId);
|
|
148
|
+
}
|
|
130
149
|
|
|
131
150
|
/** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
|
|
132
151
|
export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
|
|
152
|
+
if (caller?.node?.only && !cardAssignedToNode(row, caller.node.id)) return false;
|
|
153
|
+
return canSeeTaskAsUser(caller, row);
|
|
154
|
+
}
|
|
155
|
+
function canSeeTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
133
156
|
if (!caller) return true;
|
|
134
157
|
const m = memberRow(caller.userId, row.network_id);
|
|
135
158
|
if (!scopedRow(m)) return true;
|
|
@@ -141,6 +164,13 @@ export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
141
164
|
|
|
142
165
|
/** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
|
|
143
166
|
export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
167
|
+
if (caller?.node) {
|
|
168
|
+
const assigned = cardAssignedToNode(row, caller.node.id) && canSeeTaskAsUser(caller, row);
|
|
169
|
+
return caller.node.only ? assigned : assigned || canEditTaskAsUser(caller, row);
|
|
170
|
+
}
|
|
171
|
+
return canEditTaskAsUser(caller, row);
|
|
172
|
+
}
|
|
173
|
+
function canEditTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
|
|
144
174
|
if (!caller) return true;
|
|
145
175
|
const m = memberRow(caller.userId, row.network_id);
|
|
146
176
|
if (!scopedRow(m)) return true;
|
|
@@ -174,7 +204,7 @@ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"
|
|
|
174
204
|
|
|
175
205
|
/** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
|
|
176
206
|
export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
177
|
-
if (!caller) return false;
|
|
207
|
+
if (!caller || caller.node) return false; // 参与人身份是人的;节点按 canEditTask 判
|
|
178
208
|
const m = memberRow(caller.userId, row.network_id);
|
|
179
209
|
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
180
210
|
return participates(row, caller.userId);
|
|
@@ -258,6 +288,14 @@ export function canUseProject(caller: TaskCaller, networkId: string, projectId:
|
|
|
258
288
|
* 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
|
|
259
289
|
*/
|
|
260
290
|
export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
291
|
+
const scoped = addTaskVisibilityScopeAsUser(sql, params, caller);
|
|
292
|
+
if (!caller?.node?.only) return scoped;
|
|
293
|
+
// 受限节点:再只留派给它的卡(及其子任务)。
|
|
294
|
+
const pat = params.push(nodeRefPattern(caller.node.id));
|
|
295
|
+
const mine = (t: string) => `${t}agent_owner_json LIKE ?${pat} ESCAPE '\\' OR ${t}participants_json LIKE ?${pat} ESCAPE '\\' OR ${t}created_by_json LIKE ?${pat} ESCAPE '\\'`;
|
|
296
|
+
return `${scoped} AND (${mine("")} OR parent_id IN (SELECT p.requirement_id FROM requirements p WHERE ${mine("p.")}))`;
|
|
297
|
+
}
|
|
298
|
+
function addTaskVisibilityScopeAsUser(sql: string, params: unknown[], caller: TaskCaller): string {
|
|
261
299
|
if (!caller) return sql;
|
|
262
300
|
const scoped = taskScopedNetworks(caller.userId);
|
|
263
301
|
if (!scoped.length) return sql;
|
package/src/tools.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
12
12
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
13
13
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
14
14
|
import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
15
|
-
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
15
|
+
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
16
|
+
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
16
17
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
17
18
|
import { errorBody } from "./requirements-errors.js";
|
|
18
19
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
@@ -200,11 +201,69 @@ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[],
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// RFC-041 第一阶段(#487):节点令牌的每个 MCP 工具先按 NODE_TOOL_CLASS 判一次(node-permissions.ts)。
|
|
205
|
+
// log 模式下正常节点照常放行、只记录;显式的只读 / 受限模式立刻生效。requirements_* 在 requirements.ts 里按卡判。
|
|
206
|
+
function guardNodePermissionTools(server: McpServer, tokenId: string, networkId: string): void {
|
|
207
|
+
const denied = (reason: Parameters<typeof nodePermissionDeniedBody>[0], route: string) => ({
|
|
208
|
+
content: [{ type: "text" as const, text: JSON.stringify(nodePermissionDeniedBody(reason, route)) }],
|
|
209
|
+
});
|
|
210
|
+
const taskTarget = (taskId: unknown): { to: AgentRef; from: string | null } | null => {
|
|
211
|
+
if (typeof taskId !== "string" || !taskId) return null;
|
|
212
|
+
const t = db.get<{ to_name: string | null; to_node_id: string | null; from_name: string | null }>(
|
|
213
|
+
"SELECT to_name, to_node_id, from_name FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
|
|
214
|
+
return t ? { to: { alias: t.to_name, nodeId: t.to_node_id }, from: t.from_name } : null;
|
|
215
|
+
};
|
|
216
|
+
const aliasTarget = (alias: unknown): AgentRef | null => {
|
|
217
|
+
if (typeof alias !== "string" || !alias.trim()) return null;
|
|
218
|
+
const canonical = resolveCanonicalAlias(networkId, alias.trim()).alias;
|
|
219
|
+
const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", canonical, networkId);
|
|
220
|
+
return { alias: canonical, nodeId: session?.node_id ?? null };
|
|
221
|
+
};
|
|
222
|
+
const verdictFor = (id: NodeIdentity, name: string, args: Record<string, unknown>): Verdict => {
|
|
223
|
+
switch (NODE_TOOL_CLASS[name]) {
|
|
224
|
+
case "dispatch": {
|
|
225
|
+
if (name === "retry_task" || name === "cancel_task") {
|
|
226
|
+
const t = taskTarget(args.task_id);
|
|
227
|
+
if (!t) return null; // 没这个任务:交给工具自己回 not found
|
|
228
|
+
if (name === "cancel_task" && t.from && id.aliases.includes(t.from)) return null; // 撤回自己派的
|
|
229
|
+
return dispatchVerdict(id, t.to);
|
|
230
|
+
}
|
|
231
|
+
const target = aliasTarget(name === "reassign_task" ? args.new_alias : args.alias);
|
|
232
|
+
return target ? dispatchVerdict(id, target) : null;
|
|
233
|
+
}
|
|
234
|
+
case "broadcast": return broadcastVerdict(id);
|
|
235
|
+
case "node_write": return writeVerdict(id, name);
|
|
236
|
+
case "human_only": return humanOnlyVerdict(id, name);
|
|
237
|
+
default: return null; // always / read / requirements(按卡判)/ 没归类的(test 保证没有)
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
241
|
+
const cls = NODE_TOOL_CLASS[name];
|
|
242
|
+
if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
|
|
243
|
+
const id = nodeIdentity(tokenId, networkId);
|
|
244
|
+
const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
|
|
245
|
+
const verdict = id ? verdictFor(id, name, args) : null;
|
|
246
|
+
if (id && nodeDecide(id, `mcp:${name}`, verdict)) return denied(verdict!.reason, `mcp:${name}`);
|
|
247
|
+
}
|
|
248
|
+
return handler(...callArgs);
|
|
249
|
+
};
|
|
250
|
+
const anyServer = server as any;
|
|
251
|
+
for (const method of ["tool", "registerTool"] as const) {
|
|
252
|
+
const original = anyServer[method].bind(server);
|
|
253
|
+
anyServer[method] = (...regArgs: any[]) => {
|
|
254
|
+
const last = regArgs.length - 1;
|
|
255
|
+
if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
|
|
256
|
+
return original(...regArgs);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
203
261
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
204
262
|
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
205
263
|
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
206
264
|
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
207
265
|
if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
|
|
266
|
+
if (callerTokenIsNetwork && callerTokenId && enforceNetworkId) guardNodePermissionTools(server, callerTokenId, enforceNetworkId);
|
|
208
267
|
// Default from_session for outbound tools — extracted from the calling
|
|
209
268
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
210
269
|
// an agent's send_task call always claimed from='hub' and peer agents
|