@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,260 @@
1
+ // Coverage for the RFC-024 patch validators / classifiers. Pinned at
2
+ // the helper layer so the contract (allowlist + security-flag gate +
3
+ // per-field range/enum + apply-mode classification) can't drift
4
+ // silently. SEC-2 fail-closed regression guard included — any future
5
+ // "convenience" change that lets non-admin remote-flip a security flag
6
+ // fails these tests.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ ALLOWED_FLAGS,
11
+ SECURITY_SENSITIVE_FLAGS,
12
+ isAllowedToChangeFlag,
13
+ computeApplyMode,
14
+ validatePatch,
15
+ } from "./config-apply-validate";
16
+
17
+ describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
18
+ test("contains exactly the 5 fields from RFC-024 §4 (teammateMode dropped per #290 review)", () => {
19
+ // teammateMode was originally listed in the 6-flag scope but
20
+ // investigation found it's only consumed by the claude-code-cli
21
+ // spawn path (agent-network/bin/cli.ts), NOT by the agent-node-
22
+ // driven runtimes the config-apply pipeline targets. Including it
23
+ // would silently ack `applied` for no-op changes — same class as
24
+ // the BLOCKER 2 schema-mismatch issue. P2 to add a claude-code-cli
25
+ // config-apply path.
26
+ expect([...ALLOWED_FLAGS].sort()).toEqual([
27
+ "budget",
28
+ "dangerouslySkipPermissions",
29
+ "maxTurns",
30
+ "permissionMode",
31
+ "timeout",
32
+ ]);
33
+ });
34
+ });
35
+
36
+ describe("SECURITY_SENSITIVE_FLAGS — privilege-elevation surface", () => {
37
+ test("contains the 2 privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
38
+ expect([...SECURITY_SENSITIVE_FLAGS].sort()).toEqual([
39
+ "dangerouslySkipPermissions",
40
+ "permissionMode",
41
+ ]);
42
+ });
43
+
44
+ test("every security-sensitive flag is also in ALLOWED_FLAGS (sanity)", () => {
45
+ for (const f of SECURITY_SENSITIVE_FLAGS) expect(ALLOWED_FLAGS.has(f)).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("isAllowedToChangeFlag — SEC-2 admin-gate (final policy 2026-06-28)", () => {
50
+ // Security-sensitive flags require admin role on the caller's
51
+ // network. Any non-admin role is rejected. Harmless flags fall
52
+ // through (`canWrite` upstream handles role !== viewer).
53
+
54
+ test("member role + dangerouslySkipPermissions → reject", () => {
55
+ const r = isAllowedToChangeFlag("member", { dangerouslySkipPermissions: true });
56
+ expect(r).not.toBeNull();
57
+ expect(r?.field).toBe("flags.dangerouslySkipPermissions");
58
+ expect(r?.reason).toMatch(/admin or owner/i);
59
+ });
60
+
61
+ test("admin role + dangerouslySkipPermissions → pass (allowed)", () => {
62
+ expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: false })).toBeNull();
63
+ });
64
+
65
+ test("admin role + permissionMode → pass", () => {
66
+ expect(isAllowedToChangeFlag("admin", { permissionMode: "default" })).toBeNull();
67
+ });
68
+
69
+ test("teammateMode dropped from schema (P1 scope) — no role check fires for it", () => {
70
+ // teammateMode no longer in SECURITY_SENSITIVE_FLAGS, so SEC-2 gate
71
+ // doesn't fire. The patch-allowlist gate (validatePatch) rejects it
72
+ // separately as "not in allowlist", which is the correct surface.
73
+ expect(isAllowedToChangeFlag("admin", { teammateMode: true } as any)).toBeNull();
74
+ });
75
+
76
+ test("owner role + permissionMode → pass (owner is higher than admin in anet RBAC)", () => {
77
+ // owner > admin in the network_members hierarchy (auth.ts line 354
78
+ // explicitly treats owner as the highest tier; updateMemberRole
79
+ // refuses to assign owner). The SEC-2 gate uses an admin-or-above
80
+ // semantic so the highest-privilege user isn't accidentally locked
81
+ // out of a permission their subordinates can use.
82
+ expect(isAllowedToChangeFlag("owner", { permissionMode: "default" })).toBeNull();
83
+ });
84
+
85
+ test("owner role + dangerouslySkipPermissions → pass", () => {
86
+ expect(isAllowedToChangeFlag("owner", { dangerouslySkipPermissions: true })).toBeNull();
87
+ });
88
+
89
+ test("viewer role + any security flag → reject", () => {
90
+ expect(isAllowedToChangeFlag("viewer", { dangerouslySkipPermissions: true })).not.toBeNull();
91
+ });
92
+
93
+ test("null role (no network membership) + any security flag → reject", () => {
94
+ expect(isAllowedToChangeFlag(null, { dangerouslySkipPermissions: true })).not.toBeNull();
95
+ });
96
+
97
+ test("member role + mixed bag with security flag → reject (checks every flag, not just first)", () => {
98
+ const r = isAllowedToChangeFlag("member", { permissionMode: "default", dangerouslySkipPermissions: true });
99
+ expect(r).not.toBeNull();
100
+ });
101
+
102
+ test("admin role + mixed bag (security + harmless) → pass", () => {
103
+ expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: true, maxTurns: 50 })).toBeNull();
104
+ });
105
+
106
+ test("member role + only harmless flags (maxTurns) → pass", () => {
107
+ expect(isAllowedToChangeFlag("member", { maxTurns: 50 })).toBeNull();
108
+ });
109
+
110
+ test("member role + only harmless flags (maxTurns + budget) → pass", () => {
111
+ expect(isAllowedToChangeFlag("member", { maxTurns: 50, budget: 100 })).toBeNull();
112
+ });
113
+
114
+ test("any role + empty flag bag → pass (no flags to gate)", () => {
115
+ expect(isAllowedToChangeFlag(null, {})).toBeNull();
116
+ expect(isAllowedToChangeFlag("viewer", {})).toBeNull();
117
+ expect(isAllowedToChangeFlag("member", {})).toBeNull();
118
+ expect(isAllowedToChangeFlag("admin", {})).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("computeApplyMode — tier classifier (RFC-024 §4)", () => {
123
+ test("empty patch (model + flags both empty) → restart_only", () => {
124
+ expect(computeApplyMode(undefined, {})).toBe("restart_only");
125
+ });
126
+
127
+ test("model alone → restart (SDK reinit needed)", () => {
128
+ expect(computeApplyMode("claude-opus-4", {})).toBe("restart");
129
+ });
130
+
131
+ test("flags.permissionMode alone → restart", () => {
132
+ expect(computeApplyMode(undefined, { permissionMode: "auto" })).toBe("restart");
133
+ });
134
+
135
+ test("flags.dangerouslySkipPermissions alone → restart", () => {
136
+ expect(computeApplyMode(undefined, { dangerouslySkipPermissions: true })).toBe("restart");
137
+ });
138
+
139
+ test("teammateMode dropped from RESTART_REQUIRED_FLAGS (no longer in scope)", () => {
140
+ // teammateMode no longer in RESTART_REQUIRED_FLAGS. computeApplyMode
141
+ // sees only unknown-to-tier-table flags → returns "hot" by default.
142
+ // The patch-allowlist gate (validatePatch) rejects it separately.
143
+ expect(computeApplyMode(undefined, { teammateMode: false } as any)).toBe("hot");
144
+ });
145
+
146
+ test("flags.timeout alone → restart (per RFC-024 §4 — module-level const today)", () => {
147
+ expect(computeApplyMode(undefined, { timeout: 60000 })).toBe("restart");
148
+ });
149
+
150
+ test("flags.maxTurns alone → hot", () => {
151
+ expect(computeApplyMode(undefined, { maxTurns: 50 })).toBe("hot");
152
+ });
153
+
154
+ test("flags.budget alone → hot", () => {
155
+ expect(computeApplyMode(undefined, { budget: 100 })).toBe("hot");
156
+ });
157
+
158
+ test("mixed (model + maxTurns) → restart (strictest wins)", () => {
159
+ expect(computeApplyMode("gpt-5", { maxTurns: 50 })).toBe("restart");
160
+ });
161
+
162
+ test("mixed (only hot flags) → hot", () => {
163
+ expect(computeApplyMode(undefined, { maxTurns: 50, budget: 100 })).toBe("hot");
164
+ });
165
+
166
+ test("mixed (hot + one restart flag) → restart", () => {
167
+ expect(computeApplyMode(undefined, { maxTurns: 50, timeout: 60000 })).toBe("restart");
168
+ });
169
+ });
170
+
171
+ describe("validatePatch — model field", () => {
172
+ test("undefined model passes (no change)", () => {
173
+ expect(validatePatch(undefined, {})).toBeNull();
174
+ });
175
+ test("valid model string passes", () => {
176
+ expect(validatePatch("claude-opus-4", {})).toBeNull();
177
+ });
178
+ test("empty model string rejected", () => {
179
+ const r = validatePatch("", {});
180
+ expect(r?.field).toBe("model");
181
+ });
182
+ test("model >200 chars rejected", () => {
183
+ const r = validatePatch("x".repeat(201), {});
184
+ expect(r?.field).toBe("model");
185
+ });
186
+ test("non-string model rejected", () => {
187
+ const r = validatePatch(123 as any, {});
188
+ expect(r?.field).toBe("model");
189
+ });
190
+ });
191
+
192
+ describe("validatePatch — flag allowlist (anything not in ALLOWED_FLAGS rejected)", () => {
193
+ test("unknown flag rejected", () => {
194
+ const r = validatePatch(undefined, { mysteryFlag: 1 } as any);
195
+ expect(r?.field).toBe("flags.mysteryFlag");
196
+ expect(r?.reason).toMatch(/allowlist/);
197
+ });
198
+ test("typo of allowed flag rejected (no fuzzy match)", () => {
199
+ const r = validatePatch(undefined, { permission_mode: "auto" } as any);
200
+ expect(r?.field).toBe("flags.permission_mode");
201
+ });
202
+ });
203
+
204
+ describe("validatePatch — per-flag enum / range", () => {
205
+ test("permissionMode valid values", () => {
206
+ for (const v of ["default", "auto", "bypassPermissions", "acceptEdits", "plan"]) {
207
+ expect(validatePatch(undefined, { permissionMode: v })).toBeNull();
208
+ }
209
+ });
210
+ test("permissionMode invalid value rejected", () => {
211
+ const r = validatePatch(undefined, { permissionMode: "godmode" } as any);
212
+ expect(r?.field).toBe("flags.permissionMode");
213
+ });
214
+ test("dangerouslySkipPermissions must be boolean", () => {
215
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: true })).toBeNull();
216
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: false })).toBeNull();
217
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: "true" } as any)?.field).toBe("flags.dangerouslySkipPermissions");
218
+ expect(validatePatch(undefined, { dangerouslySkipPermissions: 1 } as any)?.field).toBe("flags.dangerouslySkipPermissions");
219
+ });
220
+ test("teammateMode rejected as not-in-allowlist (dropped from P1 scope)", () => {
221
+ // teammateMode was originally allowed but dropped per #290 review
222
+ // (no consumer in agent-node runtimes). validatePatch now rejects
223
+ // it the same way it rejects any unknown flag.
224
+ const r = validatePatch(undefined, { teammateMode: true } as any);
225
+ expect(r?.field).toBe("flags.teammateMode");
226
+ expect(r?.reason).toMatch(/allowlist/);
227
+ });
228
+ test("maxTurns must be integer in [0, 10000]", () => {
229
+ expect(validatePatch(undefined, { maxTurns: 0 })).toBeNull();
230
+ expect(validatePatch(undefined, { maxTurns: 10000 })).toBeNull();
231
+ expect(validatePatch(undefined, { maxTurns: -1 })?.field).toBe("flags.maxTurns");
232
+ expect(validatePatch(undefined, { maxTurns: 10001 })?.field).toBe("flags.maxTurns");
233
+ expect(validatePatch(undefined, { maxTurns: 1.5 })?.field).toBe("flags.maxTurns");
234
+ expect(validatePatch(undefined, { maxTurns: "5" } as any)?.field).toBe("flags.maxTurns");
235
+ });
236
+ test("budget must be number in [0, 1_000_000]", () => {
237
+ expect(validatePatch(undefined, { budget: 0 })).toBeNull();
238
+ expect(validatePatch(undefined, { budget: 500.5 })).toBeNull();
239
+ expect(validatePatch(undefined, { budget: 1_000_000 })).toBeNull();
240
+ expect(validatePatch(undefined, { budget: -1 })?.field).toBe("flags.budget");
241
+ expect(validatePatch(undefined, { budget: 1_000_001 })?.field).toBe("flags.budget");
242
+ });
243
+ test("timeout must be integer ms in [0, 3_600_000]", () => {
244
+ expect(validatePatch(undefined, { timeout: 30000 })).toBeNull();
245
+ expect(validatePatch(undefined, { timeout: 3_600_000 })).toBeNull();
246
+ expect(validatePatch(undefined, { timeout: -1 })?.field).toBe("flags.timeout");
247
+ expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
248
+ expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
249
+ });
250
+ });
251
+
252
+ describe("validatePatch — combinations", () => {
253
+ test("valid model + valid flag bag passes", () => {
254
+ expect(validatePatch("claude-opus-4", { maxTurns: 50, budget: 100 })).toBeNull();
255
+ });
256
+ test("first invalid field surfaces (good model + bad flag)", () => {
257
+ const r = validatePatch("claude-opus-4", { maxTurns: -1 });
258
+ expect(r?.field).toBe("flags.maxTurns");
259
+ });
260
+ });
@@ -0,0 +1,171 @@
1
+ // Pure validators / classifiers for RFC-024 node-config-apply. Extracted
2
+ // from server/src/tools.ts so the contract (allowlist, security-sensitive
3
+ // flag set, range/enum rules, apply-mode classification) is unit-testable
4
+ // without spinning up an MCP server. The corresponding tool handlers in
5
+ // tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
6
+ // caller identity, role gates).
7
+
8
+ /**
9
+ * Fields the dashboard may change. Anything not in this list is rejected
10
+ * by hub-side validation regardless of role — the UI cannot smuggle
11
+ * extra keys past this gate.
12
+ */
13
+ export const ALLOWED_FLAGS = new Set<string>([
14
+ "permissionMode",
15
+ "dangerouslySkipPermissions",
16
+ "maxTurns",
17
+ "budget",
18
+ "timeout",
19
+ ]);
20
+ // NB on teammateMode (dropped from P1 scope per #290 cross-agent
21
+ // review): teammateMode is consumed ONLY by the claude-code-cli
22
+ // spawn path in agent-network/bin/cli.ts (passed as --teammate-mode
23
+ // CLI arg). The agent-node-driven runtimes (claude-agent-sdk /
24
+ // codex-sdk / grok-build-acp) that the config-apply pipeline targets
25
+ // do NOT consume it. Including it in the allowlist would silently
26
+ // ack `applied` for changes that have zero effect — same class as
27
+ // the BLOCKER 2 schema-mismatch issue. P2: add a claude-code-cli
28
+ // config-apply path that respawns the CLI process.
29
+
30
+ /**
31
+ * Security-sensitive flags — remote changes are privilege-elevation
32
+ * operations. Final policy (decided 2026-06-28 by 通信龙 per Vincent
33
+ * autonomy grant): **caller role must equal `admin` to flip these
34
+ * flags remotely.** Non-admin requests are rejected with
35
+ * `insufficient_role_for_security_flag`. Cross-network requests are
36
+ * also blocked but via SEC-1 (network scope), not this gate.
37
+ *
38
+ * Dashboard is expected to grey out these inputs for non-admin
39
+ * sessions, but hub does NOT trust the dashboard's UI gate — every
40
+ * tool call is re-checked here. `curl` direct to `/mcp` is a real
41
+ * attack vector.
42
+ */
43
+ export const SECURITY_SENSITIVE_FLAGS = new Set<string>([
44
+ "permissionMode",
45
+ "dangerouslySkipPermissions",
46
+ ]);
47
+
48
+ /**
49
+ * Flags that require a node process restart (not in-process hot reload).
50
+ * Used by computeApplyMode + the per-tool tier classifier. `timeout`
51
+ * sits here today because CLAUDE_TIMEOUT_MS is a module-level const at
52
+ * boot; P2 will move it to per-think read which would let it become hot.
53
+ */
54
+ export const RESTART_REQUIRED_FLAGS = new Set<string>([
55
+ "permissionMode",
56
+ "dangerouslySkipPermissions",
57
+ "timeout",
58
+ ]);
59
+
60
+ /**
61
+ * Role-gate for the patch's flag set. SEC-2 enforcement lives here.
62
+ *
63
+ * Returns null on pass, or `{ field, reason }` on reject. The reject
64
+ * payload shape matches the tool handler's error envelope so the
65
+ * caller can forward it without re-shaping.
66
+ *
67
+ * Policy (final, 2026-06-28):
68
+ * - security-sensitive flags → caller role MUST be admin-or-above
69
+ * (admin OR owner; owner > admin in the anet RBAC, per auth.ts
70
+ * network_members hierarchy — viewer < member < admin < owner)
71
+ * - harmless flags → handled by upstream `canWrite` (role !== viewer);
72
+ * this helper passes them through (no per-flag gate beyond
73
+ * allowlist/range validation)
74
+ *
75
+ * Caller must pass the role string resolved from the user's
76
+ * network_members row (not the token's bearer-level role). Owners
77
+ * are NOT included via "admin ⊇ owner" inheritance — they're a
78
+ * distinct higher tier; the check is explicit OR so any future role
79
+ * (e.g. "super_admin") needs an explicit allowlist update.
80
+ */
81
+ const SECURITY_ADMIN_ROLES = new Set<string>(["admin", "owner"]);
82
+
83
+ export function isAllowedToChangeFlag(
84
+ role: string | null,
85
+ patchFlags: Record<string, unknown>,
86
+ ): { field: string; reason: string } | null {
87
+ for (const key of Object.keys(patchFlags)) {
88
+ if (SECURITY_SENSITIVE_FLAGS.has(key)) {
89
+ if (!role || !SECURITY_ADMIN_ROLES.has(role)) {
90
+ return {
91
+ field: `flags.${key}`,
92
+ reason: "remote change of security-sensitive flags requires admin or owner role on this network",
93
+ };
94
+ }
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Classify the patch's required apply mode. Empty patch (model + flags
102
+ * both empty) → "restart_only" (used by restart_node tool). Any
103
+ * restart-required field present → "restart". Otherwise → "hot".
104
+ */
105
+ export function computeApplyMode(
106
+ model: string | undefined,
107
+ flags: Record<string, unknown>,
108
+ ): "hot" | "restart" | "restart_only" {
109
+ const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
110
+ if (fieldCount === 0) return "restart_only";
111
+ if (model !== undefined) return "restart";
112
+ for (const key of Object.keys(flags)) {
113
+ if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
114
+ }
115
+ return "hot";
116
+ }
117
+
118
+ /**
119
+ * Validate the patch shape — allowlist + per-field range / enum. Returns
120
+ * null on pass, or `{ field, reason }` on reject. Caller wraps in error
121
+ * envelope.
122
+ */
123
+ export function validatePatch(
124
+ model: string | undefined,
125
+ flags: Record<string, unknown>,
126
+ ): { field: string; reason: string } | null {
127
+ if (model !== undefined) {
128
+ if (typeof model !== "string" || model.length === 0 || model.length > 200) {
129
+ return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
130
+ }
131
+ }
132
+ for (const [key, val] of Object.entries(flags)) {
133
+ if (!ALLOWED_FLAGS.has(key)) {
134
+ return { field: `flags.${key}`, reason: "not in allowlist" };
135
+ }
136
+ switch (key) {
137
+ case "permissionMode":
138
+ if (
139
+ typeof val !== "string" ||
140
+ !["default", "auto", "bypassPermissions", "acceptEdits", "plan"].includes(val)
141
+ ) {
142
+ return {
143
+ field: "flags.permissionMode",
144
+ reason: "must be one of default/auto/bypassPermissions/acceptEdits/plan",
145
+ };
146
+ }
147
+ break;
148
+ case "dangerouslySkipPermissions":
149
+ if (typeof val !== "boolean") {
150
+ return { field: `flags.${key}`, reason: "must be boolean" };
151
+ }
152
+ break;
153
+ case "maxTurns":
154
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 10000) {
155
+ return { field: "flags.maxTurns", reason: "must be an integer in [0, 10000]" };
156
+ }
157
+ break;
158
+ case "budget":
159
+ if (typeof val !== "number" || val < 0 || val > 1000000) {
160
+ return { field: "flags.budget", reason: "must be a number in [0, 1_000_000]" };
161
+ }
162
+ break;
163
+ case "timeout":
164
+ if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
165
+ return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
166
+ }
167
+ break;
168
+ }
169
+ }
170
+ return null;
171
+ }