@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.
- package/package.json +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
|
@@ -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
|
+
}
|