unbound-cli 1.20.1 → 1.20.3
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/src/toolHealth.js +17 -2
- package/test/pack-export.test.js +9 -0
- package/test/pack-plan.test.js +17 -0
- package/test/tool-health.test.js +25 -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/src/toolHealth.js
CHANGED
|
@@ -206,12 +206,27 @@ function envKeyCheck(label, name, kind = 'aux') {
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
function configDeviceToken() {
|
|
210
|
+
try {
|
|
211
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.unbound', 'config.json'), 'utf8'));
|
|
212
|
+
return cfg.api_key || cfg.device_token || null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Env wins when present so a stale exported key is still validated; setup keeps the token in ~/.unbound/config.json and removes the env var.
|
|
210
219
|
function deviceTokenCheck(legacyName) {
|
|
211
220
|
const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
|
|
212
221
|
if (check.ok) return check;
|
|
213
222
|
const legacy = envKeyCheck('API key env', legacyName);
|
|
214
|
-
|
|
223
|
+
if (legacy.ok) return legacy;
|
|
224
|
+
const value = configDeviceToken();
|
|
225
|
+
if (!value) return check;
|
|
226
|
+
return {
|
|
227
|
+
name: 'API key env', ok: true, kind: 'aux', detail: 'device token set (config.json)', summary: 'API key set',
|
|
228
|
+
keyCheck: true, envName: 'device token', value, candidates: [value],
|
|
229
|
+
};
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
function envCheck(label, name, expected, kind = 'aux') {
|
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] };
|
package/test/tool-health.test.js
CHANGED
|
@@ -1111,6 +1111,31 @@ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () =>
|
|
|
1111
1111
|
assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
|
|
1112
1112
|
});
|
|
1113
1113
|
|
|
1114
|
+
test('claude-code: device token in config.json and no env → healthy', () => {
|
|
1115
|
+
const t = withHome((tmp, th) => {
|
|
1116
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
1117
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
1118
|
+
writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'k' }));
|
|
1119
|
+
return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
|
|
1120
|
+
});
|
|
1121
|
+
assert.equal(t.status, 'healthy');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test('claude-code: an exported key is the one validated even when config.json holds another', () => {
|
|
1125
|
+
process.env.UNBOUND_DEVICE_TOKEN = 'from-env';
|
|
1126
|
+
try {
|
|
1127
|
+
const t = withHome((tmp, th) => {
|
|
1128
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
1129
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
1130
|
+
writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'from-config' }));
|
|
1131
|
+
return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
|
|
1132
|
+
});
|
|
1133
|
+
assert.equal(t.checks.find((c) => c.keyCheck).value, 'from-env');
|
|
1134
|
+
} finally {
|
|
1135
|
+
delete process.env.UNBOUND_DEVICE_TOKEN;
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1114
1139
|
test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
|
|
1115
1140
|
const t = claudeWithEnv({});
|
|
1116
1141
|
assert.equal(t.status, 'tampered');
|