unbound-cli 1.18.0 → 1.20.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.
- package/README.md +63 -0
- package/package.json +3 -2
- package/src/auth-pages.js +3 -2
- package/src/auth.js +12 -5
- package/src/commands/login.js +24 -2
- package/src/commands/pack.js +566 -0
- package/src/commands/status.js +9 -1
- package/src/index.js +11 -0
- package/src/lib/pack-export.js +136 -0
- package/src/lib/pack-plan.js +515 -0
- package/src/lib/pack-remote.js +308 -0
- package/src/lib/pack-schema.js +328 -0
- package/src/login-scope.js +85 -0
- package/test/auth-server-leak.test.js +8 -1
- package/test/login-scope-callback.test.js +79 -0
- package/test/login-scope.test.js +51 -0
- package/test/pack-confirm.test.js +27 -0
- package/test/pack-export.test.js +83 -0
- package/test/pack-plan.test.js +201 -0
- package/test/pack-remote.test.js +179 -0
- package/test/pack-schema.test.js +144 -0
|
@@ -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
|
+
};
|