unbound-cli 1.17.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.
package/src/toolHealth.js CHANGED
@@ -206,6 +206,14 @@ function envKeyCheck(label, name, kind = 'aux') {
206
206
  };
207
207
  }
208
208
 
209
+ // Setup writes UNBOUND_DEVICE_TOKEN; installs from before it carry only the per-tool name.
210
+ function deviceTokenCheck(legacyName) {
211
+ const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
212
+ if (check.ok) return check;
213
+ const legacy = envKeyCheck('API key env', legacyName);
214
+ return legacy.ok ? legacy : check;
215
+ }
216
+
209
217
  function envCheck(label, name, expected, kind = 'aux') {
210
218
  const base = label.replace(/ env$/, '');
211
219
  const r = readEnvVar(name);
@@ -334,7 +342,7 @@ function buildVariants(gatewayUrl, binaryPath) {
334
342
  checks: () => [
335
343
  configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
336
344
  scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
337
- envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
345
+ deviceTokenCheck('UNBOUND_CURSOR_API_KEY'),
338
346
  ],
339
347
  },
340
348
  {
@@ -342,7 +350,7 @@ function buildVariants(gatewayUrl, binaryPath) {
342
350
  checks: () => [
343
351
  configCheck('Config', claudePath('settings.json'), { json: true, test: refsUnbound }),
344
352
  scriptCheck('Hook script', claudePath('hooks', 'unbound.py')),
345
- envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
353
+ deviceTokenCheck('UNBOUND_CLAUDE_API_KEY'),
346
354
  ],
347
355
  },
348
356
  {
@@ -373,7 +381,7 @@ function buildVariants(gatewayUrl, binaryPath) {
373
381
  return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
374
382
  } }, { short: 'codex hooks not enabled', kind: 'aux' }), // aux: an org install leaves it set with no user hook
375
383
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
376
- envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
384
+ deviceTokenCheck('UNBOUND_CODEX_API_KEY'),
377
385
  ],
378
386
  },
379
387
  {
@@ -397,7 +405,7 @@ function buildVariants(gatewayUrl, binaryPath) {
397
405
  const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
398
406
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
399
407
  else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
400
- checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
408
+ checks.push(deviceTokenCheck('UNBOUND_COPILOT_API_KEY'));
401
409
  return checks;
402
410
  },
403
411
  },
@@ -414,7 +422,7 @@ function buildVariants(gatewayUrl, binaryPath) {
414
422
  const checks = [configCheck('Config', '~/.augment/settings.json', { json: false, test: (t) => t.includes('unbound.py') || t.includes(BINARY_MARKER) })];
415
423
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
416
424
  else checks.push(scriptCheck('Hook script', '~/.augment/hooks/unbound.py'));
417
- checks.push(envKeyCheck('API key env', 'UNBOUND_AUGMENT_API_KEY'));
425
+ checks.push(deviceTokenCheck('UNBOUND_AUGMENT_API_KEY'));
418
426
  return checks;
419
427
  },
420
428
  },
@@ -0,0 +1,46 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ function withConfig(contents, fn) {
8
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-token-'));
9
+ const origHome = process.env.HOME;
10
+ process.env.HOME = tmp;
11
+ const file = path.join(tmp, '.unbound', 'config.json');
12
+ if (contents) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, JSON.stringify(contents));
15
+ }
16
+ delete require.cache[require.resolve('../src/config')];
17
+ try {
18
+ return fn(require('../src/config'), file);
19
+ } finally {
20
+ process.env.HOME = origHome;
21
+ delete require.cache[require.resolve('../src/config')];
22
+ fs.rmSync(tmp, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ test('getDeviceKey prefers api_key, which an older writer may have updated alone', () => {
27
+ withConfig({ device_token: 'stale', api_key: 'relogged' }, (config) => {
28
+ assert.equal(config.getDeviceKey(), 'relogged');
29
+ });
30
+ });
31
+
32
+ test('getDeviceKey falls back to device_token', () => {
33
+ withConfig({ device_token: 'new' }, (config) => {
34
+ assert.equal(config.getDeviceKey(), 'new');
35
+ });
36
+ });
37
+
38
+ test('setDeviceKey writes both fields so older CLIs and hooks still read it', () => {
39
+ withConfig({ api_key: 'old', base_url: 'https://api.acme.com' }, (config, file) => {
40
+ config.setDeviceKey('k2');
41
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ assert.equal(written.device_token, 'k2');
43
+ assert.equal(written.api_key, 'k2');
44
+ assert.equal(written.base_url, 'https://api.acme.com');
45
+ });
46
+ });
@@ -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
+ });
@@ -0,0 +1,179 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const path = require('node:path');
4
+
5
+ // Stub the HTTP layer before pack-remote loads it.
6
+ const calls = [];
7
+ const stub = {
8
+ get: async (p, opts) => { calls.push(['GET', p, opts]); return stub.onGet(p, opts); },
9
+ post: async (p, opts) => { calls.push(['POST', p, opts]); return stub.onPost(p, opts); },
10
+ put: async (p, opts) => { calls.push(['PUT', p, opts]); return stub.onPut(p, opts); },
11
+ del: async (p, opts) => { calls.push(['DELETE', p, opts]); return stub.onDel(p, opts); },
12
+ onGet: async () => ({}),
13
+ onPost: async () => ({ id: '99' }),
14
+ onPut: async () => ({}),
15
+ onDel: async () => ({}),
16
+ };
17
+ require.cache[require.resolve('../src/api')] = { id: path.resolve(__dirname, '../src/api.js'), filename: path.resolve(__dirname, '../src/api.js'), loaded: true, exports: stub };
18
+ const remote = require('../src/lib/pack-remote');
19
+
20
+ function reset() {
21
+ calls.length = 0;
22
+ stub.onGet = async () => ({});
23
+ stub.onPost = async () => ({ id: '99' });
24
+ stub.onPut = async () => ({});
25
+ stub.onDel = async () => ({});
26
+ }
27
+
28
+ test('replaceRow creates under a temporary name before deleting the old row', async () => {
29
+ reset();
30
+ await remote.replaceRow('tool', { id: '11', name: 'Block x' }, { name: 'Block x', policy_type: 'MCP_TOOL', code_pack: 'base', code_slug: 'x' });
31
+ assert.deepEqual(calls.map((c) => c[0]), ['POST', 'DELETE', 'PUT']);
32
+ assert.match(calls[0][2].body.name, /^Block x-replacing-/);
33
+ assert.equal(calls[1][1], '/api/v1/command-policies/11/');
34
+ assert.deepEqual(calls[2], ['PUT', '/api/v1/command-policies/99/', { body: { name: 'Block x', code_pack: 'base', code_slug: 'x', origin: 'CODE' } }]);
35
+ });
36
+
37
+ test('replaceRow: a failed create leaves the old row in place', async () => {
38
+ reset();
39
+ stub.onPost = async () => { throw new Error('boom'); };
40
+ await assert.rejects(() => remote.replaceRow('generic', { id: 5, name: 'base/y' }, { name: 'base/y', type: 'COST' }), /boom/);
41
+ assert.deepEqual(calls.map((c) => c[0]), ['POST'], 'no delete after a failed create');
42
+ });
43
+
44
+ test('replaceRow reads the id from the generic {policy: ...} envelope', async () => {
45
+ reset();
46
+ stub.onPost = async () => ({ policy: { id: 42 } });
47
+ await remote.replaceRow('generic', { id: 5, name: 'base/y' }, { name: 'base/y', type: 'COST' });
48
+ assert.equal(calls[2][1], '/api/v1/policies/42/');
49
+ });
50
+
51
+ test('writeOps never throws for one failed op and reports every outcome', async () => {
52
+ reset();
53
+ stub.onPut = async () => { throw new Error('nope'); };
54
+ const results = await remote.writeOps([
55
+ { op: 'create', table: 'generic', body: { name: 'base/a' } },
56
+ { op: 'update', table: 'tool', row: { id: '1' }, body: { action: 'AUDIT' } },
57
+ { op: 'unchanged', table: 'tool', row: { id: '2' } },
58
+ ]);
59
+ assert.deepEqual(results.map((r) => r.status), ['ok', 'failed', 'skipped']);
60
+ assert.equal(results[1].error, 'nope');
61
+ });
62
+
63
+ test('fetchManaged asks for the pack by identity and splits unsupported types', async () => {
64
+ reset();
65
+ stub.onGet = async (p) => {
66
+ if (p.startsWith('/api/v1/command-policies/')) {
67
+ return { policies: [
68
+ { id: '1', name: 'Audit pushes', policy_type: 'TERMINAL_COMMAND', origin: 'CODE', code_pack: 'base', code_slug: 'a' },
69
+ { id: '2', name: 'Skill', policy_type: 'SKILL_INJECTION', origin: 'CODE', code_pack: 'base', code_slug: 'skill' },
70
+ { id: '3', name: 'base/a', policy_type: 'TERMINAL_COMMAND', origin: 'MANUAL' },
71
+ ], total_pages: 1 };
72
+ }
73
+ return { policies: [
74
+ { id: 10, name: 'Cap', type: 'COST', origin: 'CODE', code_pack: 'base', code_slug: 'cost' },
75
+ { id: 11, name: 'Repo', type: 'REPO', origin: 'CODE', code_pack: 'base', code_slug: 'repo' },
76
+ { id: 12, name: 'Other', type: 'COST', origin: 'CODE', code_pack: 'other', code_slug: 'x' },
77
+ ] };
78
+ };
79
+ const live = await remote.fetchManaged('base');
80
+ assert.deepEqual(live.tool.map((r) => r.name), ['Audit pushes'], 'a MANUAL row named like a pack policy is not managed');
81
+ assert.deepEqual(live.generic.map((r) => r.name), ['Cap'], 'a row of another pack is not managed');
82
+ assert.deepEqual(live.unsupported.map((u) => u.row.name), ['Skill', 'Repo']);
83
+ assert.deepEqual(calls[0][2].query, { code_pack: 'base', origin: 'CODE', page: 1, page_size: 200 });
84
+ assert.deepEqual(calls[1][2].query, { code_pack: 'base', origin: 'CODE' });
85
+ });
86
+
87
+ test('fetchAllGrouped walks every page and groups by code_pack', async () => {
88
+ reset();
89
+ stub.onGet = async (p, opts) => {
90
+ if (p.startsWith('/api/v1/command-policies/')) {
91
+ const page = opts.query.page;
92
+ return { policies: [{ id: String(page), name: `policy ${page}`, policy_type: 'MCP_TOOL', origin: 'CODE', code_pack: `p${page}`, code_slug: 'a' }], total_pages: 2 };
93
+ }
94
+ return { policies: [{ id: 1, name: 'loose', type: 'COST', origin: 'MANUAL' }] };
95
+ };
96
+ const { packs, unmanaged } = await remote.fetchAllGrouped();
97
+ assert.deepEqual([...packs.keys()].sort(), ['p1', 'p2']);
98
+ assert.equal(unmanaged.length, 1);
99
+ });
100
+
101
+ test('replaceRow uses a temporary name and identity and rolls back when the old delete fails', async () => {
102
+ reset();
103
+ const slug = remote.tempSlug('a-very-long-policy-identifier-that-is-close-to-the-sixty-four-limit');
104
+ assert.ok(/^[a-z0-9-]{1,64}$/.test(slug), slug);
105
+ assert.match(remote.tempName('Block rm'), /^Block rm-replacing-/);
106
+ stub.onDel = async (p) => { if (p.endsWith('/11/')) throw new Error('old delete failed'); return {}; };
107
+ await assert.rejects(() => remote.replaceRow('tool', { id: '11', name: 'x' }, { name: 'x', code_pack: 'base', code_slug: 'x' }), /old delete failed/);
108
+ assert.deepEqual(calls.map((c) => [c[0], c[1]]), [['POST', '/api/v1/command-policies/'], ['DELETE', '/api/v1/command-policies/11/'], ['DELETE', '/api/v1/command-policies/99/']]);
109
+ assert.match(calls[0][2].body.code_slug, /^x-replacing-/);
110
+ assert.equal(calls[0][2].body.code_pack, 'base');
111
+ });
112
+
113
+ test('createdId tolerates the response envelopes the CLI accepts', () => {
114
+ assert.equal(remote.createdId('tool', { id: '7', name: 'x' }), '7');
115
+ assert.equal(remote.createdId('tool', { command_policy: { id: '8' } }), '8');
116
+ assert.equal(remote.createdId('tool', { policy: { id: '9' } }), '9');
117
+ assert.equal(remote.createdId('generic', { policy: { id: 10 } }), 10);
118
+ assert.equal(remote.createdId('generic', {}), null);
119
+ assert.equal(remote.createdId('generic', null), null);
120
+ });
121
+
122
+ test('findByExactName tags rows of unmanaged types as unsupported', async () => {
123
+ reset();
124
+ stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
125
+ ? { policies: [{ id: '1', name: 'Skill', policy_type: 'SKILL_INJECTION' }, { id: '2', name: 'Skill2', policy_type: 'MCP_TOOL' }], total_pages: 1 }
126
+ : { policies: [] });
127
+ const hits = await remote.findByExactName('Skill');
128
+ assert.equal(hits.length, 1);
129
+ assert.equal(hits[0].supported, false);
130
+ const ok = await remote.findByExactName('Skill2');
131
+ assert.equal(ok[0].supported, true);
132
+ });
133
+
134
+ test('every write declares origin CODE; delete carries it as a query parameter', async () => {
135
+ reset();
136
+ await remote.createRow('tool', { name: 'base/x', action: 'AUDIT' });
137
+ await remote.updateRow('generic', 23, { enabled: true });
138
+ await remote.deleteRow('tool', '11');
139
+ assert.deepEqual(calls[0], ['POST', '/api/v1/command-policies/', { body: { name: 'base/x', action: 'AUDIT', origin: 'CODE' } }]);
140
+ assert.deepEqual(calls[1], ['PUT', '/api/v1/policies/23/', { body: { enabled: true, origin: 'CODE' } }]);
141
+ assert.deepEqual(calls[2], ['DELETE', '/api/v1/command-policies/11/', { query: { origin: 'CODE' } }]);
142
+ });
143
+
144
+ test('loadReference records why the MCP catalogue is missing instead of hiding it', async () => {
145
+ reset();
146
+ remote.resetReference();
147
+ stub.onGet = async (p) => {
148
+ if (p.includes('mcp-servers')) throw new Error('503 from gateway-data');
149
+ if (p.includes('labels')) return { labels: [] };
150
+ return { user_groups: [], guardrails: [], ai_models: [], tool_types: [] };
151
+ };
152
+ const ref = await remote.loadReference({ mcp: true });
153
+ assert.deepEqual(ref.mcpServers, []);
154
+ assert.match(ref.mcpServersError, /503 from gateway-data/);
155
+ remote.resetReference();
156
+ });
157
+
158
+ test('a backend without the origin field is refused before any write', async () => {
159
+ reset();
160
+ stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
161
+ ? { policies: [{ id: '1', name: 'Block rm', policy_type: 'TERMINAL_COMMAND' }], total_pages: 1 }
162
+ : { policies: [{ id: 10, name: 'Cap', type: 'COST' }] });
163
+ await assert.rejects(() => remote.fetchManaged('base'), /newer server release/);
164
+ await assert.rejects(() => remote.fetchAllGrouped(), /newer server release/);
165
+ // An empty org cannot be told apart and simply has nothing to manage yet.
166
+ stub.onGet = async () => ({ policies: [], total_pages: 1 });
167
+ const live = await remote.fetchManaged('base');
168
+ assert.deepEqual(live, { tool: [], generic: [], unsupported: [] });
169
+ });
170
+
171
+ test('fetchAllNames maps every live name to its owner', async () => {
172
+ reset();
173
+ stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
174
+ ? { policies: [{ id: '1', name: 'Block rm', policy_type: 'TERMINAL_COMMAND', origin: 'MANUAL' }], total_pages: 1 }
175
+ : { policies: [{ id: 10, name: 'Cap', type: 'COST', origin: 'CODE', code_pack: 'base', code_slug: 'cap' }] });
176
+ const names = await remote.fetchAllNames();
177
+ assert.deepEqual(names.get('Block rm'), { table: 'tool', id: '1', code_pack: null, code_slug: null });
178
+ assert.deepEqual(names.get('Cap'), { table: 'generic', id: 10, code_pack: 'base', code_slug: 'cap' });
179
+ });