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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Scope flags for `unbound login`: what the minted login token may do and for how long.
3
+ * Parsed before the browser opens so a typo fails here, not after the round trip.
4
+ */
5
+ const AREAS = ['analytics', 'logs', 'policies', 'configuration'];
6
+ const LEVELS = ['none', 'view', 'edit'];
7
+ const EXPIRY_DAYS = [7, 30, 60, 90];
8
+ const ROLE_NAME_MAX_LENGTH = 64;
9
+
10
+ // Built-in roles by their shell-friendly slug; custom role names pass through verbatim.
11
+ const BUILTIN_ROLE_SLUGS = {
12
+ admin: 'Admin',
13
+ 'analytics-viewer': 'Analytics Viewer',
14
+ manager: 'Manager',
15
+ member: 'Member',
16
+ };
17
+
18
+ const AREA_LABELS = { analytics: 'Analytics', logs: 'Logs', policies: 'Policies', configuration: 'Configuration' };
19
+
20
+ function parsePermissions(raw) {
21
+ const permissions = Object.fromEntries(AREAS.map((area) => [area, 'none']));
22
+ for (const pair of String(raw).split(',')) {
23
+ const parts = pair.trim().split('=');
24
+ const [area, level] = parts;
25
+ if (parts.length !== 2 || !area || !level) throw new Error(`Invalid permissions entry "${pair.trim()}". Use area=level, e.g. analytics=view`);
26
+ if (!AREAS.includes(area)) throw new Error(`Unknown area "${area}". Areas: ${AREAS.join(', ')}`);
27
+ if (!LEVELS.includes(level)) throw new Error(`Unknown level "${level}" for ${area}. Levels: ${LEVELS.join(', ')}`);
28
+ if (area === 'logs' && level === 'edit') throw new Error('Logs has no edit level');
29
+ permissions[area] = level;
30
+ }
31
+ return permissions;
32
+ }
33
+
34
+ /**
35
+ * Turns the login flags into the query the dashboard reads. Returns {} when no flag was given.
36
+ * @param {{role?: string, permissions?: string, expiresInDays?: string|number}} opts
37
+ */
38
+ function parseLoginScope(opts = {}) {
39
+ const query = {};
40
+ const role = typeof opts.role === 'string' ? opts.role.trim() : '';
41
+ const permissions = typeof opts.permissions === 'string' ? opts.permissions.trim() : '';
42
+ if (opts.role !== undefined && !role) throw new Error('--role must not be empty');
43
+ if (opts.permissions !== undefined && !permissions) throw new Error('--permissions must not be empty');
44
+ if (role && permissions) throw new Error('Use --role or --permissions, not both');
45
+ if (opts.expiresInDays !== undefined) {
46
+ const days = Number(opts.expiresInDays);
47
+ if (!EXPIRY_DAYS.includes(days)) throw new Error(`--expires-in-days must be one of ${EXPIRY_DAYS.join(', ')}`);
48
+ query.expires_in_days = String(days);
49
+ }
50
+ if (role) {
51
+ const slug = role.toLowerCase();
52
+ const name = Object.hasOwn(BUILTIN_ROLE_SLUGS, slug) ? BUILTIN_ROLE_SLUGS[slug] : role;
53
+ if (name.length > ROLE_NAME_MAX_LENGTH) throw new Error(`Unknown role "${role}"`);
54
+ query.role = name;
55
+ } else if (permissions) {
56
+ const map = parsePermissions(permissions);
57
+ query.permissions = AREAS.filter((a) => map[a] !== 'none').map((a) => `${a}=${map[a]}`).join(',');
58
+ if (!query.permissions) throw new Error('--permissions grants no access');
59
+ }
60
+ return query;
61
+ }
62
+
63
+ /** True when any login flag was given, so a dashboard that echoes no `scope` must have ignored it. */
64
+ function isScoped(query) {
65
+ return Object.keys(query).length > 0;
66
+ }
67
+
68
+ /** "Analytics view, Logs view" from a permissions map, or "full access" for null. */
69
+ function formatPermissions(permissions) {
70
+ if (!permissions || typeof permissions !== 'object') return 'full access';
71
+ const granted = AREAS.filter((a) => permissions[a] && permissions[a] !== 'none')
72
+ .map((a) => `${AREA_LABELS[a]} ${permissions[a]}`);
73
+ return granted.length ? granted.join(', ') : 'no access';
74
+ }
75
+
76
+ /** One line describing the token the privileges call reports, or null when the caller is not a CLI token. */
77
+ function describeToken(privileges) {
78
+ const token = privileges && privileges.cli_token;
79
+ if (!token || typeof token !== 'object') return null;
80
+ const parts = [token.name, formatPermissions(token.permissions)];
81
+ if (token.expires_at) parts.push(`expires ${String(token.expires_at).slice(0, 10)}`);
82
+ return parts.filter(Boolean).join(' · ');
83
+ }
84
+
85
+ module.exports = { parseLoginScope, isScoped, formatPermissions, describeToken, BUILTIN_ROLE_SLUGS, EXPIRY_DAYS };
@@ -1,6 +1,7 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
3
  const http = require('http');
4
+ const os = require('os');
4
5
  const { URL } = require('url');
5
6
 
6
7
  // WEB-4941, root cause #1: loginWithBrowser's local callback server leaked a
@@ -105,8 +106,11 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
105
106
  let successHeaders;
106
107
  let successSocket;
107
108
 
109
+ let authParams;
110
+
108
111
  const fakeOpen = async (authUrl) => {
109
- port = Number(new URL(new URL(authUrl).searchParams.get('callback_url')).port);
112
+ authParams = new URL(authUrl).searchParams;
113
+ port = Number(new URL(authParams.get('callback_url')).port);
110
114
  const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
111
115
  successHeaders = res.headers;
112
116
  successSocket = res.socket;
@@ -120,6 +124,9 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
120
124
  );
121
125
 
122
126
  assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
127
+ // The dashboard names this machine's login token after `machine`, so a re-login replaces it.
128
+ assert.equal(authParams.get('app_type'), 'cli');
129
+ assert.equal(authParams.get('machine'), os.hostname());
123
130
  assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
124
131
 
125
132
  // The leak-proof: the held keep-alive socket is actually closed by the
@@ -0,0 +1,79 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const { URL } = require('url');
5
+
6
+ // The browser round trip for a scoped login: the flags ride on the auth URL, the dashboard's
7
+ // `scope=` echo comes back on success, and its `error=` reason is what the user sees on failure.
8
+
9
+ const auth = require('../src/auth');
10
+ const config = require('../src/config');
11
+
12
+ function get(url) {
13
+ return new Promise((resolve, reject) => {
14
+ http.get(url, (res) => {
15
+ let body = '';
16
+ res.on('data', (chunk) => { body += chunk; });
17
+ res.on('end', () => resolve({ statusCode: res.statusCode, body }));
18
+ }).on('error', reject);
19
+ });
20
+ }
21
+
22
+ function withStubbedConfig(fn) {
23
+ const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
24
+ config.setApiKey = () => {};
25
+ config.writeConfig = () => {};
26
+ config.readConfig = () => ({});
27
+ return Promise.resolve().then(fn).finally(() => Object.assign(config, saved));
28
+ }
29
+
30
+ test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope comes back', async () => {
31
+ await withStubbedConfig(async () => {
32
+ let authParams;
33
+ const fakeOpen = async (authUrl) => {
34
+ authParams = new URL(authUrl).searchParams;
35
+ const callbackUrl = authParams.get('callback_url');
36
+ setImmediate(() => get(`${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme&scope=role%3AAnalytics+Viewer`));
37
+ };
38
+
39
+ const result = await auth.loginWithBrowser('http://frontend.invalid', {
40
+ open: fakeOpen, scope: { role: 'Analytics Viewer', expires_in_days: '30' },
41
+ });
42
+
43
+ assert.equal(authParams.get('role'), 'Analytics Viewer');
44
+ assert.equal(authParams.get('expires_in_days'), '30');
45
+ assert.equal(authParams.get('app_type'), 'cli');
46
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme', scope: 'role:Analytics Viewer' });
47
+ });
48
+ });
49
+
50
+ test('loginWithBrowser: control characters in the callback error never reach the terminal', async () => {
51
+ await withStubbedConfig(async () => {
52
+ const fakeOpen = async (authUrl) => {
53
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
54
+ setImmediate(() => get(`${callbackUrl}?error=${encodeURIComponent('bad\u001b[31m role\u0007')}`));
55
+ };
56
+ await assert.rejects(
57
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
58
+ (err) => err.message === 'bad[31m role',
59
+ );
60
+ });
61
+ });
62
+
63
+ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows on the failure page', async () => {
64
+ await withStubbedConfig(async () => {
65
+ let page;
66
+ const fakeOpen = async (authUrl) => {
67
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
68
+ setImmediate(async () => { page = await get(`${callbackUrl}?error=Unknown+role+%22Policy-Auditor%22`); });
69
+ };
70
+
71
+ await assert.rejects(
72
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'Policy-Auditor' } }),
73
+ /Unknown role "Policy-Auditor"/,
74
+ );
75
+ await new Promise((resolve) => setTimeout(resolve, 50));
76
+ assert.equal(page.statusCode, 400);
77
+ assert.match(page.body, /Unknown role "Policy-Auditor"/);
78
+ });
79
+ });
@@ -0,0 +1,51 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { parseLoginScope, isScoped, formatPermissions, describeToken } = require('../src/login-scope');
5
+
6
+ test('parseLoginScope: no flags means a plain login', () => {
7
+ assert.deepEqual(parseLoginScope({}), {});
8
+ assert.equal(isScoped({}), false);
9
+ });
10
+
11
+ test('parseLoginScope: a flag given but empty is an error, not a plain login', () => {
12
+ assert.throws(() => parseLoginScope({ role: ' ' }), /--role must not be empty/);
13
+ assert.throws(() => parseLoginScope({ permissions: '' }), /--permissions must not be empty/);
14
+ assert.equal(isScoped(parseLoginScope({ expiresInDays: '7' })), true);
15
+ });
16
+
17
+ test('parseLoginScope: built-in slugs map to the role name, custom names pass verbatim', () => {
18
+ assert.deepEqual(parseLoginScope({ role: 'analytics-viewer' }), { role: 'Analytics Viewer' });
19
+ assert.deepEqual(parseLoginScope({ role: 'ADMIN' }), { role: 'Admin' });
20
+ assert.deepEqual(parseLoginScope({ role: ' Policy Auditor ' }), { role: 'Policy Auditor' });
21
+ assert.deepEqual(parseLoginScope({ role: 'constructor' }), { role: 'constructor' });
22
+ assert.deepEqual(parseLoginScope({ role: '__proto__' }), { role: '__proto__' });
23
+ });
24
+
25
+ test('parseLoginScope: permissions keep only granted areas, in a fixed order', () => {
26
+ assert.deepEqual(
27
+ parseLoginScope({ permissions: 'logs=view, analytics=view', expiresInDays: '30' }),
28
+ { expires_in_days: '30', permissions: 'analytics=view,logs=view' },
29
+ );
30
+ });
31
+
32
+ test('parseLoginScope: rejects bad input before the browser opens', () => {
33
+ assert.throws(() => parseLoginScope({ role: 'admin', permissions: 'analytics=view' }), /not both/);
34
+ assert.throws(() => parseLoginScope({ expiresInDays: '45' }), /7, 30, 60, 90/);
35
+ assert.throws(() => parseLoginScope({ permissions: 'foo=view' }), /Unknown area "foo"/);
36
+ assert.throws(() => parseLoginScope({ permissions: 'analytics=admin' }), /Unknown level "admin"/);
37
+ assert.throws(() => parseLoginScope({ permissions: 'logs=edit' }), /no edit level/);
38
+ assert.throws(() => parseLoginScope({ permissions: 'analytics' }), /area=level/);
39
+ assert.throws(() => parseLoginScope({ permissions: 'analytics=none' }), /grants no access/);
40
+ assert.throws(() => parseLoginScope({ role: 'x'.repeat(65) }), /Unknown role/);
41
+ });
42
+
43
+ test('formatPermissions and describeToken read the privileges payload', () => {
44
+ assert.equal(formatPermissions(null), 'full access');
45
+ assert.equal(formatPermissions({ analytics: 'view', logs: 'view', policies: 'none', configuration: 'none' }), 'Analytics view, Logs view');
46
+ assert.equal(describeToken({ is_admin: true }), null);
47
+ assert.equal(
48
+ describeToken({ cli_token: { name: 'cli-login:mbp', permissions: null, expires_at: '2027-02-01T00:00:00Z' } }),
49
+ 'cli-login:mbp · full access · expires 2027-02-01',
50
+ );
51
+ });
@@ -0,0 +1,27 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { PassThrough } = require('node:stream');
4
+
5
+ const { confirm } = require('../src/commands/pack')._internals;
6
+
7
+ test('confirm fails closed when stdin is not a TTY', () => {
8
+ const stdin = new PassThrough();
9
+ stdin.isTTY = false;
10
+ assert.throws(() => confirm('Delete 2 policies?', { stdin }), /pass --yes/);
11
+ });
12
+
13
+ test('confirm resolves false when stdin closes without an answer', async () => {
14
+ const stdin = new PassThrough();
15
+ stdin.isTTY = true;
16
+ const p = confirm('Delete?', { stdin });
17
+ stdin.end();
18
+ assert.equal(await p, false);
19
+ });
20
+
21
+ test('confirm resolves true on y', async () => {
22
+ const stdin = new PassThrough();
23
+ stdin.isTTY = true;
24
+ const p = confirm('Delete?', { stdin });
25
+ stdin.write('y\n');
26
+ assert.equal(await p, true);
27
+ });
@@ -0,0 +1,83 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const schema = require('../src/lib/pack-schema');
5
+ const plan = require('../src/lib/pack-plan');
6
+ const exp = require('../src/lib/pack-export');
7
+
8
+ const REF = {
9
+ userGroups: [{ id: 1, name: 'Engineering' }],
10
+ guardrails: [{ id: 7, name: 'PII', supported_actions: ['BLOCK', 'AUDIT'], entities: [{ id: 70, name: 'EMAIL' }, { id: 71, name: 'SSN' }] }],
11
+ aiModels: [{ id: 100, name: 'claude-sonnet' }, { id: 101, name: 'gpt-4o' }],
12
+ toolTypes: ['cursor'],
13
+ labels: [{ id: 5, name: 'baseline' }],
14
+ mcpServers: [],
15
+ };
16
+
17
+ const LIVE = {
18
+ tool: [
19
+ { id: '11', name: 'no-rm', code_pack: 'base', code_slug: 'no-rm', policy_type: 'TERMINAL_COMMAND', action: 'BLOCK', custom_message: 'no', description: 'd', enabled: false, status: 'INACTIVE', command_family: 'custom', config: { command: 'rm -rf*' }, scope_user_groups: [{ id: 1, name: 'Engineering' }] },
20
+ { id: '12', name: 'gh-writes', code_pack: 'base', code_slug: 'gh-writes', policy_type: 'MCP_TOOL', action: 'AUDIT', custom_message: '', description: '', enabled: true, status: 'ACTIVE', mcp_server: 'GitHub', mcp_tools: ['push_files'], mcp_canonical_group: { id: 9, name: 'github' }, config: {}, scope_user_groups: [] },
21
+ ],
22
+ generic: [
23
+ { id: 21, name: 'pii', code_pack: 'base', code_slug: 'pii', type: 'SECURITY', enabled: true, priority: 3, config: { sub_type: 'guardrails', guardrails: [{ guardrail_id: 7, action: 'BLOCK', threshold: 4, enabled: true, route_model_id: null, entities: [{ entity_id: 70, enabled: true }, { entity_id: 71, enabled: false }] }] }, scope_user_groups: [], scope_tool_types: ['cursor'], labels: [{ id: 5, name: 'baseline' }] },
24
+ { id: 22, name: 'models', code_pack: 'base', code_slug: 'models', type: 'MODEL', enabled: true, priority: 1, config: { support_all_models: false, allowed_model_ids: [100], excluded_model_ids: [] }, scope_user_groups: [], scope_tool_types: [], labels: [] },
25
+ { id: 23, name: 'cap', code_pack: 'base', code_slug: 'cap', type: 'COST', enabled: true, priority: 2, config: { monthly_budget: 100 }, scope_user_groups: [], scope_tool_types: [], labels: [] },
26
+ ],
27
+ };
28
+
29
+ test('liveToPack: ids become names and sugar is restored', () => {
30
+ const doc = exp.liveToPack('base', LIVE, REF, { version: '2.0.0' });
31
+ assert.equal(doc.apiVersion, 'unbound.security/v1');
32
+ assert.deepEqual(doc.metadata, { name: 'base', version: '2.0.0' });
33
+ const byId = Object.fromEntries(doc.policies.map((p) => [p.id, p]));
34
+ assert.deepEqual(byId['no-rm'], { id: 'no-rm', kind: 'tool', type: 'TERMINAL_COMMAND', action: 'BLOCK', enabled: false, description: 'd', customMessage: 'no', commandFamily: 'custom', config: { command: 'rm -rf*' }, scope: { userGroups: ['Engineering'] } });
35
+ assert.deepEqual(byId['gh-writes'].mcp, { server: 'GitHub', tools: ['push_files'] });
36
+ assert.deepEqual(byId.pii.guardrails, [{ name: 'PII', action: 'BLOCK', threshold: 4, entities: ['EMAIL'] }]);
37
+ assert.deepEqual(byId.pii.scope, { toolTypes: ['cursor'] });
38
+ assert.deepEqual(byId.pii.labels, ['baseline']);
39
+ assert.deepEqual(byId.models.models, { allowed: ['claude-sonnet'] });
40
+ assert.deepEqual(byId.cap.config, { monthly_budget: 100 });
41
+ assert.equal(byId.cap.priority, 2);
42
+ });
43
+
44
+ test('round trip: export -> parse -> validate -> plan is a no-op', () => {
45
+ const doc = exp.liveToPack('base', LIVE, REF);
46
+ const text = exp.stringifyPack(doc);
47
+ assert.match(text, /^# PolicyPack "base"/);
48
+ const pack = schema.validatePack(schema.parsePackText(text, 'base.yaml'), 'base.yaml');
49
+ const desired = plan.toDesired(pack, REF);
50
+ const ops = plan.buildPlan(desired, LIVE, { prune: true });
51
+ assert.deepEqual(ops.map((o) => o.op), ops.map(() => 'unchanged'), JSON.stringify(ops.filter((o) => o.op !== 'unchanged').map((o) => o.changes)));
52
+ });
53
+
54
+ test('stringifyPack: JSON-compatible document without header', () => {
55
+ const doc = exp.liveToPack('base', { tool: [], generic: [LIVE.generic[2]] }, REF);
56
+ const text = exp.stringifyPack(doc, { header: false });
57
+ assert.doesNotMatch(text, /^#/);
58
+ assert.deepEqual(schema.parsePackText(text, 'x.yaml'), doc);
59
+ });
60
+
61
+ test('round trip keeps a disabled guardrail entry disabled', () => {
62
+ const live = { tool: [], generic: [{ ...LIVE.generic[0], config: { sub_type: 'guardrails', guardrails: [{ guardrail_id: 7, action: 'AUDIT', threshold: 1, enabled: false, route_model_id: null, entities: [{ entity_id: 70, enabled: true }, { entity_id: 71, enabled: true }] }] } }] };
63
+ const doc = exp.liveToPack('base', live, REF);
64
+ assert.deepEqual(doc.policies[0].guardrails, [{ name: 'PII', action: 'AUDIT', enabled: false }]);
65
+ const packDoc = schema.validatePack(doc, 'base.yaml');
66
+ const ops = plan.buildPlan(plan.toDesired(packDoc, REF), live, { prune: true });
67
+ assert.deepEqual(ops.map((o) => o.op), ['unchanged']);
68
+ });
69
+
70
+ test('export keeps a loadable file for an empty BLOCK message and an allow-nothing model policy', () => {
71
+ const live = {
72
+ tool: [{ ...LIVE.tool[0], name: 'quiet-block', code_slug: 'quiet-block', custom_message: '' }],
73
+ generic: [{ ...LIVE.generic[1], name: 'none', code_slug: 'none', config: { support_all_models: false, allowed_model_ids: [], excluded_model_ids: [] } }],
74
+ };
75
+ const doc = exp.liveToPack('base', live, REF);
76
+ const byId = Object.fromEntries(doc.policies.map((p) => [p.id, p]));
77
+ assert.equal(byId['quiet-block'].customMessage, '');
78
+ assert.equal(byId.none.models, undefined);
79
+ assert.deepEqual(byId.none.config, { support_all_models: false, allowed_model_ids: [], excluded_model_ids: [] });
80
+ const packDoc = schema.validatePack(doc, 'base.yaml');
81
+ const ops = plan.buildPlan(plan.toDesired(packDoc, REF), live, { prune: true });
82
+ assert.deepEqual(ops.map((o) => o.op), ['unchanged', 'unchanged']);
83
+ });
@@ -0,0 +1,201 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const schema = require('../src/lib/pack-schema');
5
+ const plan = require('../src/lib/pack-plan');
6
+
7
+ const REF = {
8
+ userGroups: [{ id: 1, name: 'Engineering' }, { id: 2, name: 'Contractors' }],
9
+ guardrails: [{ id: 7, name: 'PII', supported_actions: ['BLOCK', 'AUDIT'], entities: [{ id: 70, name: 'EMAIL' }, { id: 71, name: 'SSN' }] }],
10
+ aiModels: [{ id: 100, name: 'claude-sonnet' }, { id: 101, name: 'gpt-4o' }],
11
+ toolTypes: ['cursor', 'claude-code'],
12
+ labels: [{ id: 5, name: 'baseline' }],
13
+ mcpServers: [{ mcp_server: 'GitHub', tool_calls: ['push_files', 'create_pull_request', 'get_file'], tool_action_types: { push_files: 'write', create_pull_request: 'write', get_file: 'read' } }],
14
+ };
15
+
16
+ function pack(policies, extra = '') {
17
+ return schema.validatePack({
18
+ apiVersion: 'unbound.security/v1',
19
+ kind: 'PolicyPack',
20
+ metadata: { name: 'base', version: '1.0.0' },
21
+ policies,
22
+ ...(extra || {}),
23
+ }, 'base.yaml');
24
+ }
25
+
26
+ const TERMINAL = { id: 'no-rm', kind: 'tool', type: 'TERMINAL_COMMAND', action: 'BLOCK', commandFamily: 'custom', customMessage: 'no', config: { command: 'rm -rf*' }, scope: { userGroups: ['Engineering'] } };
27
+ const MCP = { id: 'gh-writes', kind: 'tool', type: 'MCP_TOOL', action: 'AUDIT', mcp: { server: 'github', actionType: 'write' } };
28
+ const SECURITY = { id: 'pii', kind: 'security', priority: 3, guardrails: [{ name: 'pii', action: 'BLOCK', entities: ['EMAIL'] }], labels: ['baseline'] };
29
+ const MODEL = { id: 'models', kind: 'model', models: { allowAll: true, excluded: ['gpt-4o'] } };
30
+ const COST = { id: 'cap', kind: 'cost', config: { monthly_budget: 100 }, scope: { toolTypes: ['cursor'] } };
31
+
32
+ const LIVE_TERMINAL = { id: '11', name: 'no-rm', code_pack: 'base', code_slug: 'no-rm', policy_type: 'TERMINAL_COMMAND', action: 'BLOCK', custom_message: 'no', description: '', enabled: true, status: 'ACTIVE', command_family: 'custom', config: { command: 'rm -rf*' }, scope_user_groups: [{ id: 1, name: 'Engineering' }] };
33
+ const LIVE_MCP = { id: '12', name: 'gh-writes', code_pack: 'base', code_slug: 'gh-writes', policy_type: 'MCP_TOOL', action: 'AUDIT', custom_message: '', description: '', enabled: true, status: 'ACTIVE', mcp_server: 'GitHub', mcp_tools: ['create_pull_request', 'push_files'], mcp_canonical_group: { id: 9, name: 'github' }, config: {}, scope_user_groups: [] };
34
+ const LIVE_SECURITY = { id: 21, name: 'pii', code_pack: 'base', code_slug: 'pii', type: 'SECURITY', enabled: true, priority: 3, config: { sub_type: 'guardrails', guardrails: [{ guardrail_id: 7, action: 'BLOCK', threshold: 1, enabled: true, route_model_id: null, entities: [{ entity_id: 70, enabled: true }, { entity_id: 71, enabled: false }] }] }, scope_user_groups: [], scope_tool_types: [], labels: [{ id: 5, name: 'baseline' }] };
35
+ const LIVE_MODEL = { id: 22, name: 'models', code_pack: 'base', code_slug: 'models', type: 'MODEL', enabled: true, priority: 1, config: { support_all_models: true, allowed_model_ids: [], excluded_model_ids: [101] }, scope_user_groups: [], scope_tool_types: [], labels: [] };
36
+ const LIVE_COST = { id: 23, name: 'cap', code_pack: 'base', code_slug: 'cap', type: 'COST', enabled: true, priority: 2, config: { monthly_budget: 100 }, scope_user_groups: [], scope_tool_types: ['cursor'], labels: [] };
37
+
38
+ test('toDesired: builds write bodies with resolved ids', () => {
39
+ const desired = plan.toDesired(pack([TERMINAL, MCP, SECURITY, MODEL, COST]), REF);
40
+ const [t, m, s, mo, c] = desired;
41
+ assert.equal(t.table, 'tool');
42
+ assert.deepEqual(t.body.scope_user_group_ids, [1]);
43
+ assert.equal(t.body.command_family, 'custom');
44
+ assert.equal(m.body.mcp_tool_action_type, 'write');
45
+ assert.deepEqual(m.proj.mcp_tools, ['create_pull_request', 'push_files'], 'action type expanded from metadata');
46
+ assert.equal(s.body.type, 'SECURITY');
47
+ assert.deepEqual(s.body.config.guardrails[0], { guardrail_id: 7, enabled: true, action: 'BLOCK', threshold: 1, route_model_id: null, entities: [{ entity_id: 70, enabled: true }] });
48
+ assert.deepEqual(s.body.label_ids, [5]);
49
+ assert.deepEqual(mo.body.config, { support_all_models: true, allowed_model_ids: [], excluded_model_ids: [101] });
50
+ assert.deepEqual(c.body.scope_tool_types, ['cursor']);
51
+ assert.equal(c.body.priority, undefined, 'priority is unmanaged when absent');
52
+ });
53
+
54
+ test('toDesired: unresolved names fail before any write', () => {
55
+ assert.throws(() => plan.toDesired(pack([{ ...TERMINAL, scope: { userGroups: ['Nope'] } }]), REF), /no user group named "Nope"/);
56
+ assert.throws(() => plan.toDesired(pack([{ ...SECURITY, guardrails: [{ name: 'Secrets', action: 'BLOCK' }] }]), REF), /no guardrail named "Secrets"/);
57
+ assert.throws(() => plan.toDesired(pack([{ ...SECURITY, guardrails: [{ name: 'PII', action: 'REDACT' }] }]), REF), /does not support REDACT/);
58
+ assert.throws(() => plan.toDesired(pack([{ ...MODEL, models: { allowed: ['llama'] } }]), REF), /no model named "llama"/);
59
+ assert.throws(() => plan.toDesired(pack([{ ...COST, scope: { toolTypes: ['vim'] } }]), REF), /unknown tool type vim/);
60
+ assert.throws(() => plan.toDesired(pack([{ ...SECURITY, labels: ['nope'] }]), REF), /no label named "nope"/);
61
+ });
62
+
63
+ test('buildPlan: in-sync org yields only unchanged ops', () => {
64
+ const desired = plan.toDesired(pack([TERMINAL, MCP, SECURITY, MODEL, COST]), REF);
65
+ const ops = plan.buildPlan(desired, { tool: [LIVE_TERMINAL, LIVE_MCP], generic: [LIVE_SECURITY, LIVE_MODEL, LIVE_COST] });
66
+ assert.deepEqual(ops.map((o) => o.op), ['unchanged', 'unchanged', 'unchanged', 'unchanged', 'unchanged']);
67
+ assert.equal(plan.summarize(ops).drift, 0);
68
+ });
69
+
70
+ test('buildPlan: create, patch-update, orphan and prune', () => {
71
+ const desired = plan.toDesired(pack([{ ...TERMINAL, action: 'AUDIT', customMessage: undefined }, { ...COST, config: { monthly_budget: 250 } }]), REF);
72
+ const live = { tool: [LIVE_TERMINAL, LIVE_MCP], generic: [LIVE_COST] };
73
+
74
+ const ops = plan.buildPlan(desired, live);
75
+ const byName = Object.fromEntries(ops.map((o) => [o.serverName, o]));
76
+ assert.equal(byName['no-rm'].op, 'update');
77
+ assert.deepEqual(byName['no-rm'].body, { action: 'AUDIT', custom_message: '' }, 'only changed fields are sent');
78
+ assert.equal(byName['cap'].op, 'update');
79
+ assert.deepEqual(byName['cap'].body, { config: { monthly_budget: 250 } });
80
+ assert.equal(byName['gh-writes'].op, 'orphan', 'without prune the orphan is kept');
81
+
82
+ const pruned = plan.buildPlan(desired, live, { prune: true });
83
+ assert.equal(pruned.find((o) => o.serverName === 'gh-writes').op, 'delete');
84
+ assert.equal(plan.summarize(pruned).delete, 1);
85
+
86
+ const created = plan.buildPlan(plan.toDesired(pack([SECURITY]), REF), { tool: [], generic: [] });
87
+ assert.equal(created[0].op, 'create');
88
+ assert.equal(created[0].body.name, 'pii');
89
+ assert.equal(created[0].body.code_pack, 'base');
90
+ assert.equal(created[0].body.code_slug, 'pii');
91
+ });
92
+
93
+ test('buildPlan: MCP tool-set change patches with the canonical group id', () => {
94
+ const desired = plan.toDesired(pack([{ ...MCP, mcp: { server: 'github', tools: ['push_files'] } }]), REF);
95
+ const [op] = plan.buildPlan(desired, { tool: [LIVE_MCP], generic: [] });
96
+ assert.equal(op.op, 'update');
97
+ assert.deepEqual(op.body, { mcp_canonical_group_id: 9, mcp_tools: ['push_files'] });
98
+ });
99
+
100
+ test('buildPlan: MCP server change and type change are replace', () => {
101
+ const server = plan.toDesired(pack([{ ...MCP, mcp: { server: 'slack', tool: 'post_message' } }]), REF);
102
+ assert.equal(plan.buildPlan(server, { tool: [LIVE_MCP], generic: [] })[0].op, 'replace');
103
+ const type = plan.toDesired(pack([{ ...TERMINAL, id: 'gh-writes' }]), REF);
104
+ assert.equal(plan.buildPlan(type, { tool: [LIVE_MCP], generic: [] })[0].op, 'replace');
105
+ });
106
+
107
+ test('an action-type selector the catalogue cannot resolve is an error, never "unchanged"', () => {
108
+ const unknownServer = { ...MCP, mcp: { server: 'nope', actionType: 'write' } };
109
+ assert.throws(() => plan.toDesired(pack([unknownServer]), REF), /not in this org's MCP catalogue/);
110
+ const emptyRef = { ...REF, mcpServers: [] };
111
+ assert.throws(() => plan.toDesired(pack([MCP]), emptyRef), /not in this org's MCP catalogue/);
112
+ const noTools = { ...MCP, mcp: { server: 'github', actionType: 'destructive' } };
113
+ assert.throws(() => plan.toDesired(pack([noTools]), REF), /has no destructive tools/);
114
+ });
115
+
116
+ test('buildPlan: conflicts are reported, never written', () => {
117
+ // same name exists in the other table
118
+ const desired = plan.toDesired(pack([{ ...COST, id: 'no-rm' }]), REF);
119
+ const [clash] = plan.buildPlan(desired, { tool: [LIVE_TERMINAL], generic: [] });
120
+ assert.equal(clash.op, 'conflict');
121
+ // generic kind change
122
+ const kindChange = plan.toDesired(pack([{ ...SECURITY, id: 'cap' }]), REF);
123
+ assert.equal(plan.buildPlan(kindChange, { tool: [], generic: [LIVE_COST] })[0].op, 'conflict');
124
+ assert.equal(plan.summarize([clash]).conflict, 1);
125
+ });
126
+
127
+ test('diffFields: scope and label order do not count as drift', () => {
128
+ const desired = plan.toDesired(pack([{ ...TERMINAL, scope: { userGroups: ['Contractors', 'Engineering'] } }]), REF);
129
+ const live = { ...LIVE_TERMINAL, scope_user_groups: [{ id: 2, name: 'Contractors' }, { id: 1, name: 'Engineering' }] };
130
+ assert.deepEqual(plan.diffFields(desired[0].proj, plan.liveProjection(live, 'tool')), []);
131
+ });
132
+
133
+ test('MCP writes keep the author casing; only the comparison is lowercased', () => {
134
+ const ref = { ...REF, mcpServers: [{ mcp_server: 'Jira', tool_calls: ['getJiraIssue', 'createIssue'], tool_action_types: { getJiraIssue: 'read', createIssue: 'write' } }] };
135
+ const desired = plan.toDesired(pack([{ ...MCP, id: 'jira', mcp: { server: 'jira', tools: ['getJiraIssue'] } }, { ...MCP, id: 'jira-writes', mcp: { server: 'jira', actionType: 'write' } }]), ref);
136
+ assert.deepEqual(desired[0].proj.mcp_tools, ['getjiraissue']);
137
+ assert.deepEqual(desired[0].body.mcp_tools, ['getJiraIssue']);
138
+ assert.deepEqual(desired[1].proj.mcp_tools, ['createissue']);
139
+ assert.equal(desired[1].body.mcp_tool_action_type, 'write');
140
+ const live = { ...LIVE_MCP, name: 'jira', code_slug: 'jira', mcp_server: 'Jira', mcp_canonical_group: { id: 4, name: 'jira' }, mcp_tools: ['createIssue'] };
141
+ const [op] = plan.buildPlan([desired[0]], { tool: [live], generic: [] });
142
+ assert.equal(op.op, 'update');
143
+ assert.deepEqual(op.body, { mcp_canonical_group_id: 4, mcp_tools: ['getJiraIssue'] });
144
+ const liveWrites = { ...live, name: 'jira-writes', code_slug: 'jira-writes', mcp_tools: ['getJiraIssue'] };
145
+ const [op2] = plan.buildPlan([desired[1]], { tool: [liveWrites], generic: [] });
146
+ assert.deepEqual(op2.body, { mcp_canonical_group_id: 4, mcp_tools: ['createIssue'] }, 'action type expansion keeps server casing');
147
+ });
148
+
149
+ test('guardrail entries can be disabled in the pack', () => {
150
+ const desired = plan.toDesired(pack([{ ...SECURITY, guardrails: [{ name: 'PII', action: 'BLOCK', enabled: false }] }]), REF);
151
+ assert.equal(desired[0].body.config.guardrails[0].enabled, false);
152
+ const live = { ...LIVE_SECURITY, config: { sub_type: 'guardrails', guardrails: [{ guardrail_id: 7, action: 'BLOCK', threshold: 1, enabled: false, route_model_id: null, entities: [{ entity_id: 70, enabled: true }, { entity_id: 71, enabled: true }] }] } };
153
+ assert.equal(plan.buildPlan(desired, { tool: [], generic: [live] })[0].op, 'unchanged');
154
+ });
155
+
156
+ test('MCP config changes are drift and patch only config', () => {
157
+ const desired = plan.toDesired(pack([{ ...MCP, mcp: { server: 'github', tools: ['create_pull_request', 'push_files'] }, config: { note: 'x' } }]), REF);
158
+ const [op] = plan.buildPlan(desired, { tool: [LIVE_MCP], generic: [] });
159
+ assert.equal(op.op, 'update');
160
+ assert.deepEqual(op.body, { config: { note: 'x' } });
161
+ });
162
+
163
+ test('an unsupported row under the desired name is a conflict, never a create', () => {
164
+ const desired = plan.toDesired(pack([{ ...COST, id: 'skill' }]), REF);
165
+ const live = { tool: [], generic: [], unsupported: [{ table: 'tool', row: { id: '7', name: 'Skill', policy_type: 'SKILL_INJECTION', origin: 'CODE', code_pack: 'base', code_slug: 'skill' } }] };
166
+ const [op] = plan.buildPlan(desired, live);
167
+ assert.equal(op.op, 'conflict');
168
+ assert.match(op.reason, /SKILL_INJECTION/);
169
+ });
170
+
171
+ test('a managed row switched off in the console is an enabled-only update', () => {
172
+ const desired = plan.toDesired(pack([TERMINAL, COST]), REF);
173
+ const live = {
174
+ tool: [{ ...LIVE_TERMINAL, origin: 'CODE', enabled: false, status: 'INACTIVE' }],
175
+ generic: [{ ...LIVE_COST, origin: 'CODE', enabled: false }],
176
+ };
177
+ const ops = plan.buildPlan(desired, live);
178
+ for (const op of ops) {
179
+ assert.equal(op.op, 'update', op.serverName);
180
+ assert.deepEqual(op.body, { enabled: true });
181
+ assert.deepEqual(op.changes, [{ field: 'enabled', from: false, to: true }]);
182
+ }
183
+ assert.equal(plan.summarize(ops).drift, 2);
184
+ });
185
+
186
+ test('a name already used elsewhere in the org is a conflict at plan time, not a failed create', () => {
187
+ const desired = plan.toDesired(pack([{ ...COST, name: 'Monthly cap' }, { ...SECURITY, name: 'PII guard' }]), REF);
188
+ const names = new Map([
189
+ ['Monthly cap', { table: 'generic', id: 77, code_pack: null, code_slug: null }],
190
+ ['PII guard', { table: 'generic', id: 21, code_pack: 'base', code_slug: 'pii' }],
191
+ ]);
192
+ const ops = plan.buildPlan(desired, { tool: [], generic: [{ ...LIVE_SECURITY, name: 'PII guard' }], names });
193
+ const byId = Object.fromEntries(ops.map((o) => [o.id, o]));
194
+ assert.equal(byId.cap.op, 'conflict');
195
+ assert.match(byId.cap.reason, /made in the console/);
196
+ assert.equal(byId.pii.op, 'unchanged', 'the row that already owns the name is not a conflict with itself');
197
+
198
+ const renamed = plan.toDesired(pack([{ ...SECURITY, name: 'Monthly cap' }]), REF);
199
+ const renamedOps = plan.buildPlan(renamed, { tool: [], generic: [{ ...LIVE_SECURITY, name: 'PII guard' }], names }, { prune: true });
200
+ assert.deepEqual(renamedOps.map((o) => o.op), ['conflict'], 'a refused rename is a conflict and never also an orphan or delete');
201
+ });