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/README.md +60 -0
- package/package.json +3 -2
- package/src/commands/pack.js +566 -0
- package/src/commands/setup.js +1 -0
- package/src/config.js +5 -1
- package/src/index.js +10 -0
- package/src/lib/pack-export.js +136 -0
- package/src/lib/pack-plan.js +515 -0
- package/src/lib/pack-remote.js +308 -0
- package/src/lib/pack-schema.js +328 -0
- package/src/toolHealth.js +13 -5
- package/test/device-token.test.js +46 -0
- package/test/pack-confirm.test.js +27 -0
- package/test/pack-export.test.js +83 -0
- package/test/pack-plan.test.js +201 -0
- package/test/pack-remote.test.js +179 -0
- package/test/pack-schema.test.js +144 -0
- package/test/tool-health.test.js +31 -0
|
@@ -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
|
+
});
|
package/test/tool-health.test.js
CHANGED
|
@@ -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
|
+
});
|