unbound-cli 1.18.0 → 1.19.0

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,136 @@
1
+ // Live policy rows -> PolicyPack document (ids back to names), and YAML output.
2
+ // Pure module. The round trip "export -> apply" must be a no-op, so every
3
+ // field the plan engine compares is written back here.
4
+
5
+ const YAML = require('yaml');
6
+ const { API_VERSION, KIND, slugify } = require('./pack-schema');
7
+ const { nameOf, liveKind } = require('./pack-plan');
8
+
9
+ function groupNames(row) {
10
+ return (row.scope_user_groups || []).map((g) => g.name).filter(Boolean);
11
+ }
12
+
13
+ function omitKeys(obj, keys) {
14
+ const out = {};
15
+ for (const [k, v] of Object.entries(obj || {})) if (!keys.includes(k)) out[k] = v;
16
+ return out;
17
+ }
18
+
19
+ /** The policy id in the file: the server identity, else a slug of the name. */
20
+ function idOf(row) {
21
+ return row.code_slug || slugify(row.name) || String(row.name);
22
+ }
23
+
24
+ function toolRowToPolicy(row) {
25
+ const p = { id: idOf(row), kind: 'tool', type: row.policy_type, action: row.action };
26
+ if (row.name !== p.id) p.name = row.name;
27
+ const enabled = row.enabled !== undefined ? !!row.enabled : row.status === 'ACTIVE';
28
+ if (!enabled) p.enabled = false;
29
+ if (row.description) p.description = row.description;
30
+ if (row.custom_message || row.action === 'BLOCK' || row.action === 'WARN') p.customMessage = row.custom_message || '';
31
+ if (row.policy_type === 'TERMINAL_COMMAND') {
32
+ p.commandFamily = row.command_family;
33
+ p.config = row.config || {};
34
+ } else {
35
+ const group = row.mcp_canonical_group && typeof row.mcp_canonical_group === 'object' ? row.mcp_canonical_group : null;
36
+ const mcp = { server: row.mcp_server || (group && group.name) || '' };
37
+ if (Array.isArray(row.mcp_tools) && row.mcp_tools.length) mcp.tools = [...row.mcp_tools];
38
+ else if (row.mcp_tool) mcp.tool = row.mcp_tool;
39
+ else if (row.mcp_tool_action_type) mcp.actionType = row.mcp_tool_action_type;
40
+ p.mcp = mcp;
41
+ if (row.config && Object.keys(row.config).length) p.config = row.config;
42
+ }
43
+ const groups = groupNames(row);
44
+ if (groups.length) p.scope = { userGroups: groups };
45
+ return p;
46
+ }
47
+
48
+ function guardrailsSugar(config, ref) {
49
+ return config.guardrails.map((g) => {
50
+ const guardrail = (ref.guardrails || []).find((it) => Number(it.id) === Number(g.guardrail_id));
51
+ const out = { name: guardrail ? guardrail.name : String(g.guardrail_id), action: g.action };
52
+ if (g.enabled === false) out.enabled = false;
53
+ if (g.threshold != null && Number(g.threshold) !== 1) out.threshold = Number(g.threshold);
54
+ const enabledIds = (g.entities || []).filter((e) => e && e.enabled !== false).map((e) => Number(e.entity_id));
55
+ const allIds = guardrail ? (guardrail.entities || []).map((e) => Number(e.id)) : [];
56
+ const allEnabled = allIds.length && allIds.every((id) => enabledIds.includes(id)) && enabledIds.length === allIds.length;
57
+ if (!allEnabled && (enabledIds.length || allIds.length)) {
58
+ out.entities = enabledIds.map((id) => {
59
+ const ent = guardrail ? (guardrail.entities || []).find((e) => Number(e.id) === id) : null;
60
+ return ent ? ent.name : String(id);
61
+ });
62
+ }
63
+ if (g.route_model_id != null) out.routeModel = nameOf(ref.aiModels, g.route_model_id);
64
+ return out;
65
+ });
66
+ }
67
+
68
+ function genericRowToPolicy(row, ref) {
69
+ const kind = liveKind(row, 'generic');
70
+ const p = { id: idOf(row), kind };
71
+ if (row.name !== p.id) p.name = row.name;
72
+ if (!row.enabled) p.enabled = false;
73
+ if (row.priority != null) p.priority = row.priority;
74
+
75
+ const config = row.config || {};
76
+ if (kind === 'security' && config.sub_type === 'guardrails' && Array.isArray(config.guardrails)) {
77
+ p.guardrails = guardrailsSugar(config, ref);
78
+ const rest = omitKeys(config, ['sub_type', 'guardrails']);
79
+ if (Object.keys(rest).length) p.config = rest;
80
+ } else if (
81
+ kind === 'model'
82
+ && ('support_all_models' in config || 'allowed_model_ids' in config || 'excluded_model_ids' in config)
83
+ // "allow nothing" has no sugar form; keep it as raw config so the file validates.
84
+ && (config.support_all_models || (config.allowed_model_ids || []).length)
85
+ ) {
86
+ const models = {};
87
+ if (config.support_all_models) {
88
+ models.allowAll = true;
89
+ if ((config.excluded_model_ids || []).length) models.excluded = config.excluded_model_ids.map((id) => nameOf(ref.aiModels, id));
90
+ } else {
91
+ models.allowed = (config.allowed_model_ids || []).map((id) => nameOf(ref.aiModels, id));
92
+ }
93
+ p.models = models;
94
+ const rest = omitKeys(config, ['support_all_models', 'allowed_model_ids', 'excluded_model_ids']);
95
+ if (Object.keys(rest).length) p.config = rest;
96
+ } else {
97
+ p.config = config;
98
+ }
99
+
100
+ const scope = {};
101
+ const groups = groupNames(row);
102
+ if (groups.length) scope.userGroups = groups;
103
+ if (Array.isArray(row.scope_tool_types) && row.scope_tool_types.length) scope.toolTypes = [...row.scope_tool_types];
104
+ if (Object.keys(scope).length) p.scope = scope;
105
+ const labels = (row.labels || []).map((l) => l.name).filter(Boolean);
106
+ if (labels.length) p.labels = labels;
107
+ return p;
108
+ }
109
+
110
+ /**
111
+ * Build a PolicyPack document from the live rows of one pack.
112
+ * live: { tool: [rows], generic: [rows] }
113
+ */
114
+ function liveToPack(packName, live, ref, { version = '1.0.0', description } = {}) {
115
+ const policies = [
116
+ ...(live.tool || []).map((r) => toolRowToPolicy(r)),
117
+ ...(live.generic || []).map((r) => genericRowToPolicy(r, ref)),
118
+ ].sort((a, b) => a.id.localeCompare(b.id));
119
+ const metadata = { name: packName, version };
120
+ if (description) metadata.description = description;
121
+ return { apiVersion: API_VERSION, kind: KIND, metadata, policies };
122
+ }
123
+
124
+ function stringifyPack(doc, { header = true } = {}) {
125
+ const body = YAML.stringify(doc, { lineWidth: 0, indent: 2 });
126
+ if (!header) return body;
127
+ return [
128
+ `# PolicyPack "${doc.metadata.name}" — managed by \`unbound pack\`.`,
129
+ '# Each policy is matched on the server by this pack name and its id; `name` is free text.',
130
+ '# Edit, bump metadata.version,',
131
+ '# then run: unbound pack apply -f <this file> --dry-run',
132
+ body,
133
+ ].join('\n');
134
+ }
135
+
136
+ module.exports = { liveToPack, stringifyPack, toolRowToPolicy, genericRowToPolicy };
@@ -0,0 +1,515 @@
1
+ // Plan engine for `unbound pack`: turns a validated pack into API write bodies,
2
+ // projects live rows into the same comparable shape, and diffs the two into a
3
+ // list of create / update / replace / delete / unchanged operations.
4
+ //
5
+ // Pure module: no network. `ref` is the reference data the remote layer loads
6
+ // once per process (user groups, guardrails, models, labels, MCP metadata).
7
+
8
+ const { PackError, GENERIC_KINDS } = require('./pack-schema');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Small helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function stable(value) {
15
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
16
+ if (value && typeof value === 'object') {
17
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
18
+ }
19
+ return JSON.stringify(value === undefined ? null : value);
20
+ }
21
+
22
+ function deepEqual(a, b) {
23
+ return stable(a) === stable(b);
24
+ }
25
+
26
+ function sortedNumbers(list) {
27
+ return [...new Set((list || []).map(Number))].sort((a, b) => a - b);
28
+ }
29
+
30
+ function sortedLower(list) {
31
+ return [...new Set((list || []).map((s) => String(s).toLowerCase()))].sort();
32
+ }
33
+
34
+ function lower(s) {
35
+ return s == null ? null : String(s).toLowerCase();
36
+ }
37
+
38
+ /**
39
+ * Resolve one name (or numeric id) against `items` ({id, name}). Case-insensitive
40
+ * on the name. Throws a PackError (exit 2) listing what is available.
41
+ */
42
+ function resolveOne(items, raw, kind, { where } = {}) {
43
+ const list = items || [];
44
+ const text = String(raw).trim();
45
+ if (/^\d+$/.test(text)) {
46
+ const hit = list.find((it) => Number(it.id) === Number(text));
47
+ if (hit) return hit;
48
+ throw new PackError(`${where ? `${where}: ` : ''}no ${kind} with id ${text}`);
49
+ }
50
+ const hit = list.find((it) => String(it.name || '').toLowerCase() === text.toLowerCase());
51
+ if (hit) return hit;
52
+ const available = list.map((it) => it.name).filter(Boolean);
53
+ throw new PackError(
54
+ `${where ? `${where}: ` : ''}no ${kind} named "${text}". Available: ${available.length ? available.join(', ') : '(none)'}`
55
+ );
56
+ }
57
+
58
+ function resolveIds(items, names, kind, opts) {
59
+ return sortedNumbers((names || []).map((n) => resolveOne(items, n, kind, opts).id));
60
+ }
61
+
62
+ function nameOf(items, id) {
63
+ const hit = (items || []).find((it) => Number(it.id) === Number(id));
64
+ return hit && hit.name ? hit.name : String(id);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Config normalisation (both sides go through the same functions)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function normalizeSecurityConfig(config) {
72
+ if (!config || typeof config !== 'object') return config || {};
73
+ if (config.sub_type !== 'guardrails' || !Array.isArray(config.guardrails)) return config;
74
+ const guardrails = config.guardrails
75
+ .map((g) => ({
76
+ guardrail_id: Number(g.guardrail_id),
77
+ action: g.action,
78
+ threshold: g.threshold == null ? 1 : Number(g.threshold),
79
+ enabled: g.enabled !== false,
80
+ route_model_id: g.route_model_id == null ? null : Number(g.route_model_id),
81
+ entities: sortedNumbers((g.entities || []).filter((e) => e && e.enabled !== false).map((e) => e.entity_id)),
82
+ }))
83
+ .sort((a, b) => a.guardrail_id - b.guardrail_id);
84
+ return { ...config, guardrails };
85
+ }
86
+
87
+ function normalizeModelConfig(config) {
88
+ if (!config || typeof config !== 'object') return config || {};
89
+ return {
90
+ ...config,
91
+ support_all_models: !!config.support_all_models,
92
+ allowed_model_ids: sortedNumbers(config.allowed_model_ids),
93
+ excluded_model_ids: sortedNumbers(config.excluded_model_ids),
94
+ };
95
+ }
96
+
97
+ function normalizeConfig(kind, config) {
98
+ if (kind === 'security') return normalizeSecurityConfig(config);
99
+ if (kind === 'model') return normalizeModelConfig(config);
100
+ return config || {};
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Pack policy -> write body + projection
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function guardrailsToConfig(policy, ref, where) {
108
+ const base = { ...(policy.config || {}) };
109
+ const out = { ...base, sub_type: base.sub_type || 'guardrails' };
110
+ out.guardrails = policy.guardrails.map((g, i) => {
111
+ const gw = `${where}.guardrails[${i}]`;
112
+ const guardrail = resolveOne(ref.guardrails, g.name, 'guardrail', { where: gw });
113
+ if (Array.isArray(guardrail.supported_actions) && !guardrail.supported_actions.includes(g.action)) {
114
+ throw new PackError(`${gw}: guardrail "${guardrail.name}" does not support ${g.action}. Supported: ${guardrail.supported_actions.join(', ')}`);
115
+ }
116
+ let entityIds;
117
+ if (g.entities) {
118
+ entityIds = g.entities.map((e) => resolveOne(guardrail.entities || [], e, `entity of guardrail "${guardrail.name}"`, { where: gw }).id);
119
+ } else {
120
+ entityIds = (guardrail.entities || []).map((e) => e.id);
121
+ }
122
+ const routeModelId = g.routeModel ? resolveOne(ref.aiModels, g.routeModel, 'model', { where: gw }).id : null;
123
+ return {
124
+ guardrail_id: guardrail.id,
125
+ enabled: g.enabled !== false,
126
+ action: g.action,
127
+ threshold: g.threshold == null ? 1 : g.threshold,
128
+ route_model_id: g.action === 'ROUTE' ? routeModelId : null,
129
+ entities: entityIds.map((id) => ({ entity_id: id, enabled: true })),
130
+ };
131
+ });
132
+ return out;
133
+ }
134
+
135
+ function modelsToConfig(policy, ref, where) {
136
+ const m = policy.models;
137
+ const allowAll = !!m.allowAll || (!!m.excluded && m.excluded.length > 0 && !(m.allowed && m.allowed.length));
138
+ return {
139
+ ...(policy.config || {}),
140
+ support_all_models: allowAll,
141
+ allowed_model_ids: allowAll ? [] : resolveIds(ref.aiModels, m.allowed, 'model', { where }),
142
+ excluded_model_ids: allowAll ? resolveIds(ref.aiModels, m.excluded, 'model', { where }) : [],
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Expand an MCP action type into the concrete tool list the backend will store,
148
+ * using the mcp-servers metadata. Returns null when the server is not in the
149
+ * metadata (null = unknown server, [] = no tool of that type).
150
+ */
151
+ function expandActionType(ref, server, actionType) {
152
+ const servers = ref.mcpServers || [];
153
+ const hits = servers.filter((s) => lower(s.mcp_server) === lower(server));
154
+ if (!hits.length) return null;
155
+ const tools = [];
156
+ for (const s of hits) {
157
+ for (const [tool, type] of Object.entries(s.tool_action_types || {})) {
158
+ if (type === actionType && !tools.includes(tool)) tools.push(tool);
159
+ }
160
+ }
161
+ // Original casing, sorted: this is what a write sends. Callers lowercase it
162
+ // themselves when they compare. A known server with no tool of this type is
163
+ // an empty list, not "unknown".
164
+ return [...tools].sort();
165
+ }
166
+
167
+ function toolDesired(policy, ref, where) {
168
+ const scopeIds = resolveIds(ref.userGroups, policy.scope && policy.scope.userGroups, 'user group', { where });
169
+ const body = {
170
+ name: policy.serverName,
171
+ description: policy.description || '',
172
+ policy_type: policy.type,
173
+ action: policy.action,
174
+ custom_message: policy.customMessage || '',
175
+ enabled: policy.enabled,
176
+ scope_user_group_ids: scopeIds,
177
+ config: policy.config || {},
178
+ };
179
+ const proj = {
180
+ name: body.name,
181
+ policy_type: body.policy_type,
182
+ action: body.action,
183
+ custom_message: body.custom_message,
184
+ description: body.description,
185
+ enabled: body.enabled,
186
+ scope_user_group_ids: scopeIds,
187
+ };
188
+ let mcp = null;
189
+ if (policy.type === 'TERMINAL_COMMAND') {
190
+ body.command_family = policy.commandFamily;
191
+ proj.command_family = policy.commandFamily;
192
+ proj.config = body.config;
193
+ } else {
194
+ const sel = policy.mcp;
195
+ body.mcp_server = sel.server;
196
+ proj.mcp_server = lower(sel.server);
197
+ // `writeTools` keeps the author's casing for the PUT body; the projection is
198
+ // lowercased only for comparison.
199
+ let writeTools = null;
200
+ if (sel.tools) {
201
+ body.mcp_tools = [...sel.tools];
202
+ writeTools = [...sel.tools];
203
+ } else if (sel.tool) {
204
+ body.mcp_tool = sel.tool;
205
+ writeTools = [sel.tool];
206
+ } else {
207
+ body.mcp_tool_action_type = sel.actionType;
208
+ writeTools = expandActionType(ref, sel.server, sel.actionType);
209
+ // A selector the CLI cannot resolve is an error, never "in sync": leaving
210
+ // the old tool set enforcing while reporting success is the worst outcome.
211
+ if (writeTools === null) {
212
+ const known = (ref.mcpServers || []).map((s) => s.mcp_server).filter(Boolean);
213
+ throw new PackError(`${where}: MCP server "${sel.server}" is not in this org's MCP catalogue, so actionType ${sel.actionType} cannot be resolved to tools${known.length ? `. Known servers: ${[...new Set(known)].sort().join(', ')}` : ''}`);
214
+ }
215
+ if (!writeTools.length) {
216
+ throw new PackError(`${where}: MCP server "${sel.server}" has no ${sel.actionType} tools in this org's catalogue; pick another actionType or list mcp.tools`);
217
+ }
218
+ }
219
+ proj.mcp_tools = sortedLower(writeTools);
220
+ proj.config = body.config;
221
+ mcp = { ...sel, writeTools };
222
+ }
223
+ return { table: 'tool', body, proj, mcp };
224
+ }
225
+
226
+ function genericDesired(policy, ref, where) {
227
+ const kind = policy.kind;
228
+ let config;
229
+ if (kind === 'security' && policy.guardrails) config = guardrailsToConfig(policy, ref, where);
230
+ else if (kind === 'model' && policy.models) config = modelsToConfig(policy, ref, where);
231
+ else config = policy.config || {};
232
+
233
+ const scopeIds = resolveIds(ref.userGroups, policy.scope && policy.scope.userGroups, 'user group', { where });
234
+ const toolTypes = [...new Set(policy.scope && policy.scope.toolTypes ? policy.scope.toolTypes : [])].sort();
235
+ if (toolTypes.length && Array.isArray(ref.toolTypes) && ref.toolTypes.length) {
236
+ const known = ref.toolTypes.map((t) => (typeof t === 'string' ? t : t.value));
237
+ const bad = toolTypes.filter((t) => !known.includes(t));
238
+ if (bad.length) throw new PackError(`${where}: unknown tool type${bad.length > 1 ? 's' : ''} ${bad.join(', ')}. Available: ${known.join(', ')}`);
239
+ }
240
+ const labelIds = resolveIds(ref.labels, policy.labels, 'label', { where });
241
+
242
+ const body = {
243
+ name: policy.serverName,
244
+ type: kind.toUpperCase(),
245
+ config,
246
+ enabled: policy.enabled,
247
+ scope_user_group_ids: scopeIds,
248
+ scope_tool_types: toolTypes,
249
+ label_ids: labelIds,
250
+ };
251
+ if (policy.priority !== undefined) body.priority = policy.priority;
252
+
253
+ const proj = {
254
+ name: body.name,
255
+ type: body.type,
256
+ enabled: body.enabled,
257
+ config: normalizeConfig(kind, config),
258
+ scope_user_group_ids: scopeIds,
259
+ scope_tool_types: toolTypes,
260
+ label_ids: labelIds,
261
+ };
262
+ if (policy.priority !== undefined) proj.priority = policy.priority;
263
+ return { table: 'generic', body, proj, mcp: null };
264
+ }
265
+
266
+ /**
267
+ * Convert every policy of a validated pack into {id, kind, serverName, table,
268
+ * body, proj}. Throws PackError on any unresolved name, before any write.
269
+ */
270
+ function toDesired(pack, ref) {
271
+ return pack.policies.map((policy) => {
272
+ const where = `policies (${policy.id})`;
273
+ const built = policy.kind === 'tool' ? toolDesired(policy, ref, where) : genericDesired(policy, ref, where);
274
+ // Identity on the server: the pack and the policy id, never the name.
275
+ built.body.code_pack = pack.name;
276
+ built.body.code_slug = policy.id;
277
+ return { id: policy.id, kind: policy.kind, serverName: policy.serverName, policy, ...built };
278
+ });
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Live row -> projection
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function liveKind(row, table) {
286
+ if (table === 'tool') return 'tool';
287
+ const t = String(row.type || '').toLowerCase();
288
+ return GENERIC_KINDS.includes(t) ? t : t || 'unknown';
289
+ }
290
+
291
+ function liveProjection(row, table) {
292
+ const scopeIds = sortedNumbers((row.scope_user_groups || []).map((g) => g.id));
293
+ if (table === 'tool') {
294
+ const proj = {
295
+ name: row.name,
296
+ policy_type: row.policy_type,
297
+ action: row.action,
298
+ custom_message: row.custom_message || '',
299
+ description: row.description || '',
300
+ enabled: row.enabled !== undefined ? !!row.enabled : row.status === 'ACTIVE',
301
+ scope_user_group_ids: scopeIds,
302
+ };
303
+ if (row.policy_type === 'TERMINAL_COMMAND') {
304
+ proj.command_family = row.command_family;
305
+ proj.config = row.config || {};
306
+ } else if (row.policy_type === 'MCP_TOOL') {
307
+ const group = row.mcp_canonical_group && typeof row.mcp_canonical_group === 'object' ? row.mcp_canonical_group : null;
308
+ proj.mcp_server = lower(row.mcp_server || (group && group.name));
309
+ proj.mcp_tools = Array.isArray(row.mcp_tools) && row.mcp_tools.length
310
+ ? sortedLower(row.mcp_tools)
311
+ : row.mcp_tool ? sortedLower([row.mcp_tool]) : [];
312
+ proj.config = row.config || {};
313
+ }
314
+ return proj;
315
+ }
316
+ const kind = liveKind(row, table);
317
+ return {
318
+ name: row.name,
319
+ type: row.type,
320
+ enabled: !!row.enabled,
321
+ config: normalizeConfig(kind, row.config),
322
+ scope_user_group_ids: scopeIds,
323
+ scope_tool_types: [...new Set(row.scope_tool_types || [])].sort(),
324
+ label_ids: sortedNumbers((row.labels || []).map((l) => l.id)),
325
+ priority: row.priority == null ? null : row.priority,
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Diff + plan
331
+ // ---------------------------------------------------------------------------
332
+
333
+ const PROJ_TO_BODY = {
334
+ tool: {
335
+ name: (d) => ({ name: d.body.name }),
336
+ action: (d) => ({ action: d.body.action }),
337
+ custom_message: (d) => ({ custom_message: d.body.custom_message }),
338
+ description: (d) => ({ description: d.body.description }),
339
+ enabled: (d) => ({ enabled: d.body.enabled }),
340
+ scope_user_group_ids: (d) => ({ scope_user_group_ids: d.body.scope_user_group_ids }),
341
+ command_family: (d) => ({ command_family: d.body.command_family, config: d.body.config }),
342
+ config: (d) => (d.body.policy_type === 'TERMINAL_COMMAND'
343
+ ? { command_family: d.body.command_family, config: d.body.config }
344
+ : { config: d.body.config }),
345
+ mcp_tools: (d, row) => {
346
+ const group = row.mcp_canonical_group && typeof row.mcp_canonical_group === 'object' ? row.mcp_canonical_group : null;
347
+ const tools = d.mcp && d.mcp.writeTools;
348
+ if (!group || !group.id || !tools) return null;
349
+ return { mcp_canonical_group_id: group.id, mcp_tools: [...tools] };
350
+ },
351
+ },
352
+ generic: {
353
+ name: (d) => ({ name: d.body.name }),
354
+ enabled: (d) => ({ enabled: d.body.enabled }),
355
+ config: (d) => ({ config: d.body.config }),
356
+ scope_user_group_ids: (d) => ({ scope_user_group_ids: d.body.scope_user_group_ids }),
357
+ scope_tool_types: (d) => ({ scope_tool_types: d.body.scope_tool_types }),
358
+ label_ids: (d) => ({ label_ids: d.body.label_ids }),
359
+ priority: (d) => ({ priority: d.body.priority }),
360
+ },
361
+ };
362
+
363
+ /** Fields of the desired projection that differ from the live projection. */
364
+ function diffFields(desiredProj, liveProj) {
365
+ const changes = [];
366
+ for (const key of Object.keys(desiredProj)) {
367
+ if (key === 'policy_type' || key === 'type') continue;
368
+ const want = desiredProj[key];
369
+ const have = liveProj[key];
370
+ if (!deepEqual(want, have)) changes.push({ field: key, from: have === undefined ? null : have, to: want });
371
+ }
372
+ return changes;
373
+ }
374
+
375
+ /** Live row of a pack with this policy id (`code_slug`); null when none. */
376
+ function findBySlug(rows, slug) {
377
+ return (rows || []).find((r) => r.code_slug === slug) || null;
378
+ }
379
+
380
+ /**
381
+ * Build the operation list.
382
+ *
383
+ * desired: output of toDesired
384
+ * live: { tool: [rows], generic: [rows] } — only rows under this pack's prefix
385
+ * options.prune: delete managed rows absent from the pack
386
+ */
387
+ function buildPlan(desired, live, { prune = false } = {}) {
388
+ const ops = [];
389
+ const matchedTool = new Set();
390
+ const matchedGeneric = new Set();
391
+ const unsupported = (live.unsupported || []).map((u) => u.row);
392
+ // Names are unique per org. `live.names` (name -> owner) lets a create or a
393
+ // rename that the server would reject show up here instead of failing mid-apply.
394
+ const names = live.names instanceof Map ? live.names : null;
395
+ const nameTakenBy = (d, ownRow) => {
396
+ if (!names) return null;
397
+ const owner = names.get(d.serverName);
398
+ if (!owner) return null;
399
+ if (ownRow && owner.table === d.table && String(owner.id) === String(ownRow.id)) return null;
400
+ return owner;
401
+ };
402
+ const nameConflict = (base, owner) => ({
403
+ ...base,
404
+ op: 'conflict',
405
+ row: null,
406
+ reason: owner.code_pack
407
+ ? `the name "${base.serverName}" is already used by ${owner.code_pack}/${owner.code_slug}; set a different name in the file`
408
+ : `the name "${base.serverName}" is already used by a policy made in the console; set a different name in the file or adopt that policy`,
409
+ });
410
+
411
+ for (const d of desired) {
412
+ const own = d.table === 'tool' ? live.tool : live.generic;
413
+ const other = d.table === 'tool' ? live.generic : live.tool;
414
+ const row = findBySlug(own, d.id);
415
+ const clash = findBySlug(other, d.id);
416
+ const blocked = findBySlug(unsupported, d.id);
417
+ const base = { id: d.id, kind: d.kind, serverName: d.serverName, table: d.table, desired: d };
418
+
419
+ if (blocked) {
420
+ const type = blocked.policy_type || blocked.type;
421
+ ops.push({ ...base, op: 'conflict', row: blocked, reason: `exists on the server as a ${type} policy, which this CLI version does not manage; delete it in the console or pick another id` });
422
+ continue;
423
+ }
424
+ if (clash) {
425
+ ops.push({ ...base, op: 'conflict', row: clash, reason: `exists on the server as a ${liveKind(clash, d.table === 'tool' ? 'generic' : 'tool')} policy; delete it or pick another id` });
426
+ continue;
427
+ }
428
+ if (!row) {
429
+ const owner = nameTakenBy(d, null);
430
+ ops.push(owner ? nameConflict(base, owner) : { ...base, op: 'create', body: d.body, changes: [] });
431
+ continue;
432
+ }
433
+ // The row is this policy's whatever happens next, so a refused rename
434
+ // never makes it an orphan as well.
435
+ (d.table === 'tool' ? matchedTool : matchedGeneric).add(row.id);
436
+ if (row.name !== d.serverName) {
437
+ const owner = nameTakenBy(d, row);
438
+ if (owner) { ops.push(nameConflict(base, owner)); continue; }
439
+ }
440
+
441
+ if (d.table === 'generic' && row.type !== d.body.type) {
442
+ ops.push({ ...base, op: 'conflict', row, reason: `exists as a ${liveKind(row, 'generic')} policy; a policy cannot change kind. Delete it or pick another id` });
443
+ continue;
444
+ }
445
+ const liveProj = liveProjection(row, d.table);
446
+ if (d.table === 'tool' && (row.policy_type !== d.body.policy_type || (d.proj.mcp_server && liveProj.mcp_server !== d.proj.mcp_server))) {
447
+ const what = row.policy_type !== d.body.policy_type ? `type ${row.policy_type} -> ${d.body.policy_type}` : `MCP server ${liveProj.mcp_server} -> ${d.proj.mcp_server}`;
448
+ ops.push({ ...base, op: 'replace', row, body: d.body, changes: [{ field: 'identity', from: row.policy_type, to: d.body.policy_type }], reason: what });
449
+ continue;
450
+ }
451
+
452
+ const changes = diffFields(d.proj, liveProj);
453
+ if (!changes.length) {
454
+ ops.push({ ...base, op: 'unchanged', row, changes: [] });
455
+ continue;
456
+ }
457
+ const body = {};
458
+ let unpatchable = null;
459
+ for (const c of changes) {
460
+ const mapper = PROJ_TO_BODY[d.table][c.field];
461
+ const patch = mapper ? mapper(d, row) : null;
462
+ if (!patch) { unpatchable = c.field; break; }
463
+ Object.assign(body, patch);
464
+ }
465
+ if (unpatchable) {
466
+ ops.push({ ...base, op: 'replace', row, body: d.body, changes, reason: `${unpatchable} cannot be patched in place` });
467
+ } else {
468
+ ops.push({ ...base, op: 'update', row, body, changes });
469
+ }
470
+ }
471
+
472
+ const orphans = [
473
+ ...(live.tool || []).filter((r) => !matchedTool.has(r.id)).map((r) => ({ row: r, table: 'tool' })),
474
+ ...(live.generic || []).filter((r) => !matchedGeneric.has(r.id)).map((r) => ({ row: r, table: 'generic' })),
475
+ ];
476
+ for (const { row, table } of orphans) {
477
+ ops.push({
478
+ op: prune ? 'delete' : 'orphan',
479
+ id: row.code_slug || row.name,
480
+ kind: liveKind(row, table),
481
+ serverName: row.name,
482
+ table,
483
+ row,
484
+ changes: [],
485
+ reason: prune ? 'not in the pack file' : 'not in the pack file (kept; pass --prune to delete)',
486
+ });
487
+ }
488
+
489
+ const order = { conflict: 0, create: 1, update: 2, replace: 3, delete: 4, orphan: 5, unchanged: 6 };
490
+ ops.sort((a, b) => order[a.op] - order[b.op] || a.serverName.localeCompare(b.serverName));
491
+ return ops;
492
+ }
493
+
494
+ function summarize(ops) {
495
+ const counts = { conflict: 0, create: 0, update: 0, replace: 0, delete: 0, orphan: 0, unchanged: 0 };
496
+ for (const op of ops) counts[op.op] = (counts[op.op] || 0) + 1;
497
+ const drift = counts.create + counts.update + counts.replace + counts.delete + counts.conflict;
498
+ return { ...counts, drift, total: ops.length };
499
+ }
500
+
501
+ module.exports = {
502
+ stable,
503
+ deepEqual,
504
+ resolveOne,
505
+ resolveIds,
506
+ nameOf,
507
+ normalizeConfig,
508
+ expandActionType,
509
+ toDesired,
510
+ liveProjection,
511
+ liveKind,
512
+ diffFields,
513
+ buildPlan,
514
+ summarize,
515
+ };