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.
@@ -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
+ });
@@ -1085,3 +1085,34 @@ test('isUnboundKeyHelper: a helper under a relocated CLAUDE_CONFIG_DIR is still
1085
1085
  delete require.cache[require.resolve('../src/toolHealth')];
1086
1086
  }
1087
1087
  });
1088
+
1089
+ // The per-tool env check accepts the shared UNBOUND_DEVICE_TOKEN or the per-tool name from older installs.
1090
+ function claudeWithEnv(env) {
1091
+ return withHome((tmp, th) => {
1092
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
1093
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
1094
+ const names = ['UNBOUND_DEVICE_TOKEN', 'UNBOUND_CLAUDE_API_KEY'];
1095
+ const saved = names.map((n) => process.env[n]);
1096
+ names.forEach((n) => delete process.env[n]);
1097
+ Object.assign(process.env, env);
1098
+ try {
1099
+ return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
1100
+ } finally {
1101
+ names.forEach((n, i) => { if (saved[i] === undefined) delete process.env[n]; else process.env[n] = saved[i]; });
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ test('claude-code: UNBOUND_DEVICE_TOKEN alone → healthy', () => {
1107
+ assert.equal(claudeWithEnv({ UNBOUND_DEVICE_TOKEN: 'k' }).status, 'healthy');
1108
+ });
1109
+
1110
+ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () => {
1111
+ assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
1112
+ });
1113
+
1114
+ test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
1115
+ const t = claudeWithEnv({});
1116
+ assert.equal(t.status, 'tampered');
1117
+ assert.ok(t.checks.some((c) => !c.ok && /UNBOUND_DEVICE_TOKEN is not set/.test(c.detail)));
1118
+ });