unbound-cli 1.20.1 → 1.20.2
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 +1 -1
- package/src/lib/pack-export.js +2 -0
- package/src/lib/pack-plan.js +24 -2
- package/test/pack-export.test.js +9 -0
- package/test/pack-plan.test.js +17 -0
package/package.json
CHANGED
package/src/lib/pack-export.js
CHANGED
|
@@ -80,6 +80,8 @@ function genericRowToPolicy(row, ref) {
|
|
|
80
80
|
} else if (
|
|
81
81
|
kind === 'model'
|
|
82
82
|
&& ('support_all_models' in config || 'allowed_model_ids' in config || 'excluded_model_ids' in config)
|
|
83
|
+
// Name patterns have no sugar form; as raw config they cannot read as "allow all".
|
|
84
|
+
&& !('allowed_model_patterns' in config)
|
|
83
85
|
// "allow nothing" has no sugar form; keep it as raw config so the file validates.
|
|
84
86
|
&& (config.support_all_models || (config.allowed_model_ids || []).length)
|
|
85
87
|
) {
|
package/src/lib/pack-plan.js
CHANGED
|
@@ -91,9 +91,26 @@ function normalizeModelConfig(config) {
|
|
|
91
91
|
support_all_models: !!config.support_all_models,
|
|
92
92
|
allowed_model_ids: sortedNumbers(config.allowed_model_ids),
|
|
93
93
|
excluded_model_ids: sortedNumbers(config.excluded_model_ids),
|
|
94
|
+
// Any match counts, so pattern order is not a change.
|
|
95
|
+
...(Array.isArray(config.allowed_model_patterns) && { allowed_model_patterns: [...config.allowed_model_patterns].sort() }),
|
|
96
|
+
...(Array.isArray(config.excluded_model_patterns) && { excluded_model_patterns: [...config.excluded_model_patterns].sort() }),
|
|
94
97
|
};
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
// The backend stores a fixed model list as exact-name patterns when every id resolves to a name one can spell; plan that stored shape so it does not drift.
|
|
101
|
+
function asStoredModelConfig(config, aiModels) {
|
|
102
|
+
const ids = config && config.allowed_model_ids;
|
|
103
|
+
if (!config || config.support_all_models !== false || 'allowed_model_patterns' in config || !Array.isArray(ids) || !ids.length || !ids.every(Number.isInteger)) return config;
|
|
104
|
+
const found = (aiModels || []).filter((m) => ids.includes(Number(m.id)));
|
|
105
|
+
// An empty list or an id missing from the catalog stays a fixed list, so nothing widens or drops.
|
|
106
|
+
if (new Set(found.map((m) => Number(m.id))).size !== new Set(ids).size) return config;
|
|
107
|
+
const names = [...new Set(found.map((m) => m.name))].sort();
|
|
108
|
+
if (!names.every((name) => /^[A-Za-z0-9._@:-]+$/.test(name))) return config;
|
|
109
|
+
// Excluded ids never applied to a fixed list, so the backend drops them on conversion too.
|
|
110
|
+
const { allowed_model_ids: _ids, excluded_model_ids: _excluded, ...rest } = config;
|
|
111
|
+
return { ...rest, support_all_models: true, allowed_model_patterns: names };
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
function normalizeConfig(kind, config) {
|
|
98
115
|
if (kind === 'security') return normalizeSecurityConfig(config);
|
|
99
116
|
if (kind === 'model') return normalizeModelConfig(config);
|
|
@@ -229,6 +246,8 @@ function genericDesired(policy, ref, where) {
|
|
|
229
246
|
if (kind === 'security' && policy.guardrails) config = guardrailsToConfig(policy, ref, where);
|
|
230
247
|
else if (kind === 'model' && policy.models) config = modelsToConfig(policy, ref, where);
|
|
231
248
|
else config = policy.config || {};
|
|
249
|
+
const unconverted = config;
|
|
250
|
+
if (kind === 'model') config = asStoredModelConfig(config, ref.aiModels);
|
|
232
251
|
|
|
233
252
|
const scopeIds = resolveIds(ref.userGroups, policy.scope && policy.scope.userGroups, 'user group', { where });
|
|
234
253
|
const toolTypes = [...new Set(policy.scope && policy.scope.toolTypes ? policy.scope.toolTypes : [])].sort();
|
|
@@ -260,7 +279,8 @@ function genericDesired(policy, ref, where) {
|
|
|
260
279
|
label_ids: labelIds,
|
|
261
280
|
};
|
|
262
281
|
if (policy.priority !== undefined) proj.priority = policy.priority;
|
|
263
|
-
|
|
282
|
+
const legacyConfig = config !== unconverted ? normalizeConfig(kind, unconverted) : undefined;
|
|
283
|
+
return { table: 'generic', body, proj, mcp: null, legacyConfig };
|
|
264
284
|
}
|
|
265
285
|
|
|
266
286
|
/**
|
|
@@ -449,7 +469,9 @@ function buildPlan(desired, live, { prune = false } = {}) {
|
|
|
449
469
|
continue;
|
|
450
470
|
}
|
|
451
471
|
|
|
452
|
-
|
|
472
|
+
let changes = diffFields(d.proj, liveProj);
|
|
473
|
+
// A model list stored as ids before the backend saved patterns is the same policy.
|
|
474
|
+
if (changes.length && d.legacyConfig && !diffFields({ ...d.proj, config: d.legacyConfig }, liveProj).length) changes = [];
|
|
453
475
|
if (!changes.length) {
|
|
454
476
|
ops.push({ ...base, op: 'unchanged', row, changes: [] });
|
|
455
477
|
continue;
|
package/test/pack-export.test.js
CHANGED
|
@@ -67,6 +67,15 @@ test('round trip keeps a disabled guardrail entry disabled', () => {
|
|
|
67
67
|
assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('a model policy stored as name patterns exports as raw config and plans unchanged', () => {
|
|
71
|
+
const live = { tool: [], generic: [{ ...LIVE.generic[1], config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet'], excluded_model_ids: [] } }] };
|
|
72
|
+
const doc = exp.liveToPack('base', live, REF);
|
|
73
|
+
assert.equal(doc.policies[0].models, undefined);
|
|
74
|
+
assert.deepEqual(doc.policies[0].config, live.generic[0].config);
|
|
75
|
+
const ops = plan.buildPlan(plan.toDesired(schema.validatePack(doc, 'base.yaml'), REF), live, { prune: true });
|
|
76
|
+
assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
|
|
77
|
+
});
|
|
78
|
+
|
|
70
79
|
test('export keeps a loadable file for an empty BLOCK message and an allow-nothing model policy', () => {
|
|
71
80
|
const live = {
|
|
72
81
|
tool: [{ ...LIVE.tool[0], name: 'quiet-block', code_slug: 'quiet-block', custom_message: '' }],
|
package/test/pack-plan.test.js
CHANGED
|
@@ -67,6 +67,23 @@ test('buildPlan: in-sync org yields only unchanged ops', () => {
|
|
|
67
67
|
assert.equal(plan.summarize(ops).drift, 0);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test('buildPlan: an allowed-model list matches the name patterns the backend stores it as', () => {
|
|
71
|
+
const desired = plan.toDesired(pack([{ ...MODEL, models: { allowed: ['gpt-4o', 'claude-sonnet'] } }]), REF);
|
|
72
|
+
assert.deepEqual(desired[0].body.config, { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] });
|
|
73
|
+
const live = { ...LIVE_MODEL, config: { support_all_models: true, allowed_model_patterns: ['claude-sonnet', 'gpt-4o'] } };
|
|
74
|
+
assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [live] }).map((o) => o.op), ['unchanged']);
|
|
75
|
+
const reordered = { ...live, config: { ...live.config, allowed_model_patterns: ['gpt-4o', 'claude-sonnet'] } };
|
|
76
|
+
assert.deepEqual(plan.buildPlan(desired, { tool: [], generic: [reordered] }).map((o) => o.op), ['unchanged']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('toDesired: an empty or partly unknown id list stays a fixed list', () => {
|
|
80
|
+
for (const ids of [[], [100, 999]]) {
|
|
81
|
+
const config = { support_all_models: false, allowed_model_ids: ids, excluded_model_ids: [] };
|
|
82
|
+
const [d] = plan.toDesired(pack([{ id: 'models', kind: 'model', config }]), REF);
|
|
83
|
+
assert.deepEqual(d.body.config, config);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
70
87
|
test('buildPlan: create, patch-update, orphan and prune', () => {
|
|
71
88
|
const desired = plan.toDesired(pack([{ ...TERMINAL, action: 'AUDIT', customMessage: undefined }, { ...COST, config: { monthly_budget: 250 } }]), REF);
|
|
72
89
|
const live = { tool: [LIVE_TERMINAL, LIVE_MCP], generic: [LIVE_COST] };
|