unbound-cli 1.18.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.
@@ -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
+ });
@@ -0,0 +1,144 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const schema = require('../src/lib/pack-schema');
5
+
6
+ const BASE = `
7
+ apiVersion: unbound.security/v1
8
+ kind: PolicyPack
9
+ metadata:
10
+ name: k8s-baseline
11
+ version: 1.2.0
12
+ defaults:
13
+ scope:
14
+ userGroups: [Engineering]
15
+ policies:
16
+ - id: block-secret-reads
17
+ kind: tool
18
+ type: TERMINAL_COMMAND
19
+ action: BLOCK
20
+ commandFamily: custom
21
+ customMessage: Reading cluster secrets is blocked.
22
+ config:
23
+ command: "regex:kubectl\\\\s+get\\\\s+secrets?"
24
+ - id: github-writes
25
+ kind: tool
26
+ type: MCP_TOOL
27
+ action: REQUIRE_SLACK_APPROVAL
28
+ mcp:
29
+ server: github
30
+ tools: [create_pull_request, push_files]
31
+ scope:
32
+ userGroups: [Contractors]
33
+ - id: pii-guard
34
+ kind: security
35
+ priority: 10
36
+ guardrails:
37
+ - name: PII
38
+ action: BLOCK
39
+ threshold: 5
40
+ - id: monthly-cap
41
+ kind: cost
42
+ enabled: false
43
+ config:
44
+ monthly_budget: 5000
45
+ `;
46
+
47
+ function load(text, file = 'pack.yaml') {
48
+ return schema.validatePack(schema.parsePackText(text, file), file);
49
+ }
50
+
51
+ test('validatePack: happy path merges defaults and builds server names', () => {
52
+ const pack = load(BASE);
53
+ assert.equal(pack.name, 'k8s-baseline');
54
+ assert.equal(pack.version, '1.2.0');
55
+ assert.equal(pack.policies.length, 4);
56
+ const [terminal, mcp, security, cost] = pack.policies;
57
+ assert.equal(terminal.serverName, 'block-secret-reads');
58
+ assert.equal(terminal.enabled, true);
59
+ assert.deepEqual(terminal.scope, { userGroups: ['Engineering'] });
60
+ assert.deepEqual(mcp.scope, { userGroups: ['Contractors'] }, 'policy scope wins over defaults');
61
+ assert.equal(security.priority, 10);
62
+ assert.equal(cost.enabled, false);
63
+ });
64
+
65
+ test('parsePackText: JSON by extension, YAML otherwise', () => {
66
+ const doc = { apiVersion: 'unbound.security/v1', kind: 'PolicyPack', metadata: { name: 'p', version: '1' }, policies: [{ id: 'a', kind: 'cost', config: { monthly_budget: 1 } }] };
67
+ assert.deepEqual(schema.parsePackText(JSON.stringify(doc), 'p.json'), doc);
68
+ assert.deepEqual(schema.parsePackText('a: 1\n', 'p.yaml'), { a: 1 });
69
+ assert.throws(() => schema.parsePackText('a: [\n', 'bad.yaml'), (err) => err instanceof schema.PackError && err.exitCode === 2);
70
+ assert.throws(() => schema.parsePackText('a: 1\n---\nb: 2\n', 'two.yaml'), /exactly one YAML document/);
71
+ });
72
+
73
+ test('validatePack: rejects wrong apiVersion and kind', () => {
74
+ assert.throws(() => load(BASE.replace('unbound.security/v1', 'unbound.security/v2')), /apiVersion must be/);
75
+ assert.throws(() => load(BASE.replace('kind: PolicyPack', 'kind: Pack')), /kind must be/);
76
+ });
77
+
78
+ test('validatePack: unknown keys fail at every level', () => {
79
+ assert.throws(() => load(`${BASE}\nextra: 1\n`), /top level: unknown key "extra"/);
80
+ assert.throws(() => load(BASE.replace(' version: 1.2.0', ' version: 1.2.0\n author: x')), /metadata: unknown key "author"/);
81
+ assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n colour: red')), /unknown key "colour"/);
82
+ assert.throws(() => load(BASE.replace(' server: github', ' server: github\n port: 1')), /mcp: unknown key "port"/);
83
+ });
84
+
85
+ test('validatePack: slug rules and duplicate ids', () => {
86
+ assert.throws(() => load(BASE.replace('name: k8s-baseline', 'name: K8s Baseline')), /metadata.name/);
87
+ assert.throws(() => load(BASE.replace('id: monthly-cap', 'id: Monthly Cap')), /policies\[3\].id/);
88
+ assert.throws(() => load(BASE.replace('id: monthly-cap', 'id: pii-guard')), /used twice/);
89
+ });
90
+
91
+ test('validatePack: kind-specific keys are enforced', () => {
92
+ // tool-only key on a generic policy
93
+ assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n action: BLOCK')), /unknown key "action"/);
94
+ // generic-only key on a tool policy
95
+ assert.throws(() => load(BASE.replace(' kind: tool\n type: TERMINAL_COMMAND', ' kind: tool\n priority: 1\n type: TERMINAL_COMMAND')), /unknown key "priority"/);
96
+ // toolTypes scope only on generic
97
+ assert.throws(() => load(BASE.replace(' userGroups: [Contractors]', ' toolTypes: [cursor]')), /scope.toolTypes applies to/);
98
+ // guardrails only on security
99
+ assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n guardrails: []')), /unknown key "guardrails"/);
100
+ });
101
+
102
+ test('validatePack: tool policy rules', () => {
103
+ assert.throws(() => load(BASE.replace('action: BLOCK\n commandFamily', 'action: DENY\n commandFamily')), /action must be one of/);
104
+ assert.throws(() => load(BASE.replace(' customMessage: Reading cluster secrets is blocked.\n', '')), /customMessage is required when action is BLOCK or WARN/);
105
+ assert.throws(() => load(BASE.replace(' commandFamily: custom\n', '')), /commandFamily is required/);
106
+ assert.throws(() => load(BASE.replace(' tools: [create_pull_request, push_files]', ' tool: a\n actionType: write')), /exactly one of tool, tools or actionType/);
107
+ assert.throws(() => load(BASE.replace(' tools: [create_pull_request, push_files]', ' actionType: nuke')), /actionType must be one of/);
108
+ });
109
+
110
+ test('validatePack: generic policy rules', () => {
111
+ assert.throws(() => load(BASE.replace(' threshold: 5', ' threshold: 500')), /threshold must be an integer from 1 to 100/);
112
+ assert.throws(() => load(BASE.replace(' action: BLOCK\n threshold: 5', ' action: ROUTE\n')), /routeModel is required when action is ROUTE/);
113
+ assert.throws(() => load(BASE.replace(' config:\n monthly_budget: 5000', ' config: {}')), /cost policy needs a config/);
114
+ assert.throws(() => load(BASE.replace(' priority: 10', ' priority: 10\n config:\n guardrails: []')), /either guardrails or config.guardrails/);
115
+ const modelBad = BASE.replace(' kind: cost\n enabled: false\n config:\n monthly_budget: 5000', ' kind: model\n models:\n allowed: [a]\n allowAll: true');
116
+ assert.throws(() => load(modelBad), /allowed cannot be combined/);
117
+ });
118
+
119
+ test('validatePack: policies must be a non-empty list', () => {
120
+ assert.throws(() => load(BASE.replace(/policies:[\s\S]*$/, 'policies: []\n')), /non-empty list/);
121
+ });
122
+
123
+ test('slugify turns a console name into a policy id', () => {
124
+ assert.equal(schema.slugify('Block kubectl secrets!'), 'block-kubectl-secrets');
125
+ assert.equal(schema.slugify(' '), null);
126
+ });
127
+
128
+ test('validatePack: name is optional free text, unique within the pack', () => {
129
+ const base = { apiVersion: 'unbound.security/v1', kind: 'PolicyPack', metadata: { name: 'p', version: '1' } };
130
+ const ok = schema.validatePack({ ...base, policies: [
131
+ { id: 'a', name: 'Monthly cap for engineering', kind: 'cost', config: { monthly_budget: 1 } },
132
+ { id: 'b', kind: 'cost', config: { monthly_budget: 2 } },
133
+ ] }, 'p.yaml');
134
+ assert.deepEqual(ok.policies.map((x) => x.serverName), ['Monthly cap for engineering', 'b']);
135
+ assert.throws(() => schema.validatePack({ ...base, policies: [{ id: 'a', name: ' ', kind: 'cost', config: { monthly_budget: 1 } }] }, 'p.yaml'), /name must be a non-empty string/);
136
+ assert.throws(() => schema.validatePack({ ...base, policies: [
137
+ { id: 'a', name: 'Same', kind: 'cost', config: { monthly_budget: 1 } }, { id: 'b', name: 'Same', kind: 'cost', config: { monthly_budget: 2 } },
138
+ ] }, 'p.yaml'), /"Same" is used twice/);
139
+ });
140
+
141
+ test('validatePack: description is a tool-policy key only', () => {
142
+ assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n description: dropped')), /unknown key "description"/);
143
+ assert.doesNotThrow(() => load(BASE.replace(' kind: tool\n type: TERMINAL_COMMAND', ' kind: tool\n description: kept\n type: TERMINAL_COMMAND')));
144
+ });