@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ // Round-5 F1+F2: cross-tenant write/inject guards.
2
+ //
3
+ // All three guards are exercised against a real in-process SQLite
4
+ // (COMMHUB_DB=/tmp/...) so we're not just unit-testing the SQL string,
5
+ // we're proving the rejection actually happens against persisted rows.
6
+ //
7
+ // Setup: two networks (A, B). Create a task in network A with a known
8
+ // task_id. Then drive the three attack vectors below from a session
9
+ // belonging to network B and assert each is blocked.
10
+ //
11
+ // Run with:
12
+ // COMMHUB_DB=/tmp/xtenant-test.db bun test src/cross-tenant-injection.test.ts
13
+
14
+ import { describe, expect, test, beforeEach } from "bun:test";
15
+ import { db, uuidv4, chainReplyToParent } from "./db.js";
16
+
17
+ function insertTask(opts: {
18
+ task_id?: string;
19
+ from_name: string;
20
+ to_name: string;
21
+ network_id: string | null;
22
+ status?: string;
23
+ parent_task_id?: string | null;
24
+ result?: string | null;
25
+ }): string {
26
+ const id = opts.task_id ?? uuidv4();
27
+ db.run(
28
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id, parent_task_id, result)
29
+ VALUES (?1, ?2, ?3, 'normal', ?4, 'test content', 'reply', datetime('now'), ?5, ?6, ?7)`,
30
+ [id, opts.from_name, opts.to_name, opts.status ?? "delivered", opts.network_id, opts.parent_task_id ?? null, opts.result ?? null]
31
+ );
32
+ return id;
33
+ }
34
+
35
+ function getTask(taskId: string) {
36
+ return db.get<{ task_id: string; status: string; result: string | null; network_id: string | null }>(
37
+ "SELECT task_id, status, result, network_id FROM tasks WHERE task_id = ?1",
38
+ taskId
39
+ );
40
+ }
41
+
42
+ function countInbox(toSession: string): number {
43
+ const row = db.get<{ cnt: number }>(
44
+ "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1",
45
+ toSession
46
+ );
47
+ return row?.cnt ?? 0;
48
+ }
49
+
50
+ beforeEach(() => {
51
+ // Clean slate per test so prior fixtures don't leak across runs.
52
+ db.run("DELETE FROM tasks");
53
+ db.run("DELETE FROM inbox");
54
+ db.run("DELETE FROM task_events");
55
+ });
56
+
57
+ // ─────────────────────────────────────────────────────────────────────
58
+ // F2 #3 — chainReplyToParent cross-network rejection
59
+ //
60
+ // The unit-level test of the most direct vector: caller in network B
61
+ // holds a child task whose `parent_task_id` points at a parent in
62
+ // network A. Without the fix, chainReplyToParent walks the link and
63
+ // writes `result` + an inbox notification into network A. With the
64
+ // fix, the function refuses to traverse and writes nothing.
65
+ // ─────────────────────────────────────────────────────────────────────
66
+
67
+ // ─────────────────────────────────────────────────────────────────────
68
+ // Round-5 follow-up — chainReplyToParent return value gates SSE push
69
+ //
70
+ // 通信牛 catch on #275: the DB write is refused on cross-network, but
71
+ // the post-call code in tools.ts was still running:
72
+ //
73
+ // SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?
74
+ // SELECT from_name, task_id ... WHERE task_id = ?
75
+ // pushEvent(parent.from_name, { parent_task_id: <netA-id>, ... },
76
+ // effectiveNetId)
77
+ //
78
+ // That pushEvent leaks the foreign parent's task_id into the caller's
79
+ // network via SSE payload. The fix: chainReplyToParent now returns
80
+ // `{ chained, stoppedReason? }` so callers can skip the follow-up
81
+ // push when the chain didn't actually write. These tests pin the
82
+ // return contract directly. The SSE-gate is exercised at the call
83
+ // sites in tools.ts; pinning the contract here ensures the call-site
84
+ // behaviour can't drift back if someone refactors db.ts.
85
+ // ─────────────────────────────────────────────────────────────────────
86
+
87
+ describe("chainReplyToParent — return value gates SSE push (round5 follow-up)", () => {
88
+ test("cross-network refusal returns { chained: false, stoppedReason: 'cross_network' }", () => {
89
+ const parentA = insertTask({
90
+ from_name: "admin-a",
91
+ to_name: "worker-a",
92
+ network_id: "net-A",
93
+ status: "delivered",
94
+ });
95
+ const childB = insertTask({
96
+ from_name: "attacker-b",
97
+ to_name: "victim-b",
98
+ network_id: "net-B",
99
+ status: "replied",
100
+ parent_task_id: parentA,
101
+ });
102
+
103
+ const result = chainReplyToParent(childB, "payload", "replied", 5, "net-B");
104
+
105
+ expect(result.chained).toBe(false);
106
+ expect(result.stoppedReason).toBe("cross_network");
107
+ });
108
+
109
+ test("legitimate same-network chain returns { chained: true } (no stoppedReason)", () => {
110
+ const parentB = insertTask({
111
+ from_name: "admin-b",
112
+ to_name: "worker-b",
113
+ network_id: "net-B",
114
+ status: "delivered",
115
+ });
116
+ const childB = insertTask({
117
+ from_name: "child-b",
118
+ to_name: "leaf-b",
119
+ network_id: "net-B",
120
+ status: "replied",
121
+ parent_task_id: parentB,
122
+ });
123
+
124
+ const result = chainReplyToParent(childB, "ok", "replied", 5, "net-B");
125
+
126
+ expect(result.chained).toBe(true);
127
+ expect(result.stoppedReason).toBeUndefined();
128
+ });
129
+
130
+ test("child with no parent returns { chained: false } (no stoppedReason)", () => {
131
+ const orphan = insertTask({
132
+ from_name: "x",
133
+ to_name: "y",
134
+ network_id: "net-B",
135
+ status: "replied",
136
+ });
137
+
138
+ const result = chainReplyToParent(orphan, "ok", "replied", 5, "net-B");
139
+
140
+ expect(result.chained).toBe(false);
141
+ expect(result.stoppedReason).toBeUndefined();
142
+ });
143
+
144
+ test("3-level chain: same-net parent writes, foreign grandparent halts → chained=true, stoppedReason='cross_network'", () => {
145
+ const grandparent = insertTask({
146
+ from_name: "gp-admin",
147
+ to_name: "gp-worker",
148
+ network_id: "net-A",
149
+ status: "delivered",
150
+ });
151
+ const parent = insertTask({
152
+ from_name: "p-admin",
153
+ to_name: "p-worker",
154
+ network_id: "net-B",
155
+ status: "delivered",
156
+ parent_task_id: grandparent,
157
+ });
158
+ const child = insertTask({
159
+ from_name: "c",
160
+ to_name: "leaf",
161
+ network_id: "net-B",
162
+ status: "replied",
163
+ parent_task_id: parent,
164
+ });
165
+
166
+ const result = chainReplyToParent(child, "ok", "replied", 5, "net-B");
167
+
168
+ // We DID write into the same-net parent before the chain halted
169
+ // at the foreign grandparent. `chained: true` lets callers push
170
+ // SSE for the legitimate parent; `stoppedReason` makes it visible
171
+ // in logs/metrics that the chain didn't run to completion.
172
+ expect(result.chained).toBe(true);
173
+ expect(result.stoppedReason).toBe("cross_network");
174
+ });
175
+ });
176
+
177
+ describe("chainReplyToParent — cross-network rejection (round5 F2 #3)", () => {
178
+ test("blocks chain when parent.network_id != callerNetId", () => {
179
+ // Network A: a task that should NEVER receive a foreign reply.
180
+ const parentA = insertTask({
181
+ from_name: "admin-a",
182
+ to_name: "worker-a",
183
+ network_id: "net-A",
184
+ status: "delivered",
185
+ });
186
+ // Network B: a child task that maliciously names parentA as its
187
+ // parent (the attack — possible because send_task wrote a row,
188
+ // and B controls the parent_task_id field of its own dispatch
189
+ // when the F2 #2 guard isn't in place either).
190
+ const childB = insertTask({
191
+ from_name: "attacker-b",
192
+ to_name: "victim-b",
193
+ network_id: "net-B",
194
+ status: "replied",
195
+ parent_task_id: parentA,
196
+ });
197
+
198
+ // Caller is in network B (effectiveNetId = "net-B"). The chain
199
+ // walks child → parentA, finds parent.network_id="net-A" !=
200
+ // caller="net-B", refuses to write.
201
+ chainReplyToParent(childB, "INJECTED PAYLOAD", "replied", 5, "net-B");
202
+
203
+ const after = getTask(parentA);
204
+ expect(after?.result).toBeNull(); // result NOT polluted
205
+ expect(after?.status).toBe("delivered"); // status NOT bumped
206
+ expect(countInbox("admin-a")).toBe(0); // no rogue inbox row
207
+ });
208
+
209
+ test("legitimate same-network chain still works", () => {
210
+ const parentB = insertTask({
211
+ from_name: "admin-b",
212
+ to_name: "worker-b",
213
+ network_id: "net-B",
214
+ status: "delivered",
215
+ });
216
+ const childB = insertTask({
217
+ from_name: "child-b",
218
+ to_name: "leaf-b",
219
+ network_id: "net-B",
220
+ status: "replied",
221
+ parent_task_id: parentB,
222
+ });
223
+
224
+ chainReplyToParent(childB, "legitimate result", "replied", 5, "net-B");
225
+
226
+ const after = getTask(parentB);
227
+ expect(after?.result).toContain("legitimate result");
228
+ expect(after?.status).toBe("replied");
229
+ expect(countInbox("admin-b")).toBe(1);
230
+ });
231
+
232
+ test("default-network (null) caller cannot chain into named network", () => {
233
+ const parentNamed = insertTask({
234
+ from_name: "admin-x",
235
+ to_name: "worker-x",
236
+ network_id: "net-X",
237
+ status: "delivered",
238
+ });
239
+ const childDefault = insertTask({
240
+ from_name: "stray",
241
+ to_name: "leaf",
242
+ network_id: null,
243
+ status: "replied",
244
+ parent_task_id: parentNamed,
245
+ });
246
+
247
+ chainReplyToParent(childDefault, "stray payload", "replied", 5, null);
248
+
249
+ expect(getTask(parentNamed)?.result).toBeNull();
250
+ expect(countInbox("admin-x")).toBe(0);
251
+ });
252
+
253
+ test("named-network caller cannot chain into default-network parent", () => {
254
+ const parentDefault = insertTask({
255
+ from_name: "admin-default",
256
+ to_name: "worker-default",
257
+ network_id: null,
258
+ status: "delivered",
259
+ });
260
+ const childNamed = insertTask({
261
+ from_name: "named",
262
+ to_name: "leaf",
263
+ network_id: "net-Y",
264
+ status: "replied",
265
+ parent_task_id: parentDefault,
266
+ });
267
+
268
+ chainReplyToParent(childNamed, "named payload", "replied", 5, "net-Y");
269
+
270
+ expect(getTask(parentDefault)?.result).toBeNull();
271
+ expect(countInbox("admin-default")).toBe(0);
272
+ });
273
+
274
+ test("recursive chain stops at the first cross-network boundary", () => {
275
+ // 3-level chain: grandparent (net-A) ← parent (net-B) ← child (net-B)
276
+ // Caller B replies. F2 #3 should write into parent (same net) but
277
+ // refuse to recurse into grandparent (foreign net).
278
+ const grandparent = insertTask({
279
+ from_name: "gp-admin",
280
+ to_name: "gp-worker",
281
+ network_id: "net-A",
282
+ status: "delivered",
283
+ });
284
+ const parent = insertTask({
285
+ from_name: "p-admin",
286
+ to_name: "p-worker",
287
+ network_id: "net-B",
288
+ status: "delivered",
289
+ parent_task_id: grandparent,
290
+ });
291
+ const child = insertTask({
292
+ from_name: "c",
293
+ to_name: "leaf",
294
+ network_id: "net-B",
295
+ status: "replied",
296
+ parent_task_id: parent,
297
+ });
298
+
299
+ chainReplyToParent(child, "result", "replied", 5, "net-B");
300
+
301
+ // Parent (same network) — chain wrote.
302
+ expect(getTask(parent)?.result).toContain("result");
303
+ expect(getTask(parent)?.status).toBe("replied");
304
+ // Grandparent (foreign network) — chain refused.
305
+ expect(getTask(grandparent)?.result).toBeNull();
306
+ expect(getTask(grandparent)?.status).toBe("delivered");
307
+ expect(countInbox("gp-admin")).toBe(0);
308
+ });
309
+
310
+ test("undefined callerNetId preserves legacy network-blind behaviour", () => {
311
+ // Internal/server-only callers that have no tenant context can
312
+ // still call chainReplyToParent without the enforcement (they
313
+ // pass undefined). This is the back-compat escape hatch.
314
+ const parent = insertTask({
315
+ from_name: "internal-admin",
316
+ to_name: "internal-worker",
317
+ network_id: "net-A",
318
+ status: "delivered",
319
+ });
320
+ const child = insertTask({
321
+ from_name: "child",
322
+ to_name: "leaf",
323
+ network_id: "net-B",
324
+ status: "replied",
325
+ parent_task_id: parent,
326
+ });
327
+
328
+ chainReplyToParent(child, "legacy result", "replied"); // no callerNetId
329
+
330
+ // Legacy behaviour: writes through regardless of network mismatch.
331
+ // Documents the back-compat surface so a future tightening is a
332
+ // deliberate decision.
333
+ expect(getTask(parent)?.result).toContain("legacy result");
334
+ });
335
+ });
package/src/db-adapter.ts CHANGED
@@ -233,8 +233,8 @@ export class PgAdapter implements DbAdapter {
233
233
  *
234
234
  * Operators who run `bun -e` snippets that touch the DB are expected to
235
235
  * `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
236
- * guard can't catch that case. The rule is documented in CLAUDE.md /
237
- * memory ([[feedback_no_prod_db_access.md]]).
236
+ * guard can't catch that case. The rule (never read or write the
237
+ * production hub database) is documented in CLAUDE.md.
238
238
  */
239
239
  export function createAdapter(): DbAdapter {
240
240
  const dbUrl = process.env.DATABASE_URL;