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.
- package/README.md +60 -0
- package/package.json +3 -2
- package/src/commands/pack.js +566 -0
- 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/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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Network layer for `unbound pack`: reference data, managed-row discovery and
|
|
2
|
+
// the sequential executor for plan operations. Everything goes through the
|
|
3
|
+
// existing /api/v1 endpoints; there is no pack endpoint on the server.
|
|
4
|
+
|
|
5
|
+
const api = require('../api');
|
|
6
|
+
const { PackError, TOOL_TYPES, GENERIC_KINDS } = require('./pack-schema');
|
|
7
|
+
|
|
8
|
+
const TOOL_PATH = '/api/v1/command-policies/';
|
|
9
|
+
const GENERIC_PATH = '/api/v1/policies/';
|
|
10
|
+
const PAGE_SIZE = 200;
|
|
11
|
+
|
|
12
|
+
// Every write declares itself as the code path. The server marks the row
|
|
13
|
+
// `origin: CODE` (so the console only lets people switch it on or off) and lets
|
|
14
|
+
// the write through its code-managed lock. Reads of managed rows filter on it.
|
|
15
|
+
const CODE_ORIGIN = 'CODE';
|
|
16
|
+
|
|
17
|
+
// One CLI invocation is one Node process, so a module-level cache is exactly
|
|
18
|
+
// one command's lifetime.
|
|
19
|
+
let _ref = null;
|
|
20
|
+
|
|
21
|
+
async function loadReference({ mcp = false } = {}) {
|
|
22
|
+
if (!_ref) {
|
|
23
|
+
const formResp = await api.get('/api/v1/policies/form_data/');
|
|
24
|
+
const data = formResp.data || formResp;
|
|
25
|
+
let labels = [];
|
|
26
|
+
try {
|
|
27
|
+
const resp = await api.get('/api/v1/labels/');
|
|
28
|
+
labels = resp.labels || [];
|
|
29
|
+
} catch (err) {
|
|
30
|
+
// An older backend without labels: label names simply cannot resolve.
|
|
31
|
+
if (!(err && err.statusCode === 404)) throw err;
|
|
32
|
+
}
|
|
33
|
+
_ref = {
|
|
34
|
+
userGroups: data.user_groups || [],
|
|
35
|
+
guardrails: data.guardrails || [],
|
|
36
|
+
aiModels: data.ai_models || [],
|
|
37
|
+
toolTypes: data.tool_types || [],
|
|
38
|
+
labels,
|
|
39
|
+
mcpServers: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (mcp && !_ref.mcpServers) {
|
|
43
|
+
try {
|
|
44
|
+
const resp = await api.get(`${TOOL_PATH}metadata/mcp-servers/`);
|
|
45
|
+
_ref.mcpServers = resp.mcp_servers || [];
|
|
46
|
+
_ref.mcpServersError = null;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Without the catalogue an action type cannot expand to tools, so the
|
|
49
|
+
// tool set is skipped in the diff; the caller warns with this reason.
|
|
50
|
+
_ref.mcpServers = [];
|
|
51
|
+
_ref.mcpServersError = err && err.message ? err.message : String(err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return _ref;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resetReference() {
|
|
58
|
+
_ref = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchAllToolRows(extraQuery = {}) {
|
|
62
|
+
const rows = [];
|
|
63
|
+
let page = 1;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const query = { ...extraQuery, page, page_size: PAGE_SIZE };
|
|
66
|
+
const data = await api.get(TOOL_PATH, { query });
|
|
67
|
+
rows.push(...(data.policies || []));
|
|
68
|
+
if (!data.total_pages || page >= data.total_pages) break;
|
|
69
|
+
page += 1;
|
|
70
|
+
}
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchAllGenericRows(extraQuery = {}) {
|
|
75
|
+
const data = await api.get(GENERIC_PATH, Object.keys(extraQuery).length ? { query: { ...extraQuery } } : undefined);
|
|
76
|
+
return data.policies || [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function classify(rows, table) {
|
|
80
|
+
const supported = [];
|
|
81
|
+
const unsupported = [];
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
const ok = table === 'tool' ? TOOL_TYPES.includes(row.policy_type) : GENERIC_KINDS.includes(String(row.type || '').toLowerCase());
|
|
84
|
+
(ok ? supported : unsupported).push(row);
|
|
85
|
+
}
|
|
86
|
+
return { supported, unsupported };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A backend without the origin field ignores the filters and returns every
|
|
91
|
+
* policy, none carrying `origin`. Applying against it would create plain
|
|
92
|
+
* console policies that the next run cannot find, so stop before any write.
|
|
93
|
+
*/
|
|
94
|
+
function requireCodeIdentitySupport(rows) {
|
|
95
|
+
if (rows.length && rows.every((row) => row.origin === undefined)) {
|
|
96
|
+
throw new PackError('this Unbound backend does not store the code identity of policies; policy packs need a newer server release');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Live rows that belong to one pack: `origin: CODE` with `code_pack` equal to
|
|
102
|
+
* the pack name. Names play no part. Rows of a policy type the CLI does not
|
|
103
|
+
* manage are returned under `unsupported` and never written.
|
|
104
|
+
*/
|
|
105
|
+
async function fetchManaged(packName) {
|
|
106
|
+
const byPack = { code_pack: packName, origin: CODE_ORIGIN };
|
|
107
|
+
// Re-checked client-side so a server that ignores the filters cannot hand
|
|
108
|
+
// back console policies as managed rows.
|
|
109
|
+
const managed = (row) => row.origin === CODE_ORIGIN && row.code_pack === packName && !!row.code_slug;
|
|
110
|
+
const [toolRows, genericRows] = await Promise.all([
|
|
111
|
+
fetchAllToolRows(byPack),
|
|
112
|
+
fetchAllGenericRows(byPack),
|
|
113
|
+
]);
|
|
114
|
+
requireCodeIdentitySupport([...toolRows, ...genericRows]);
|
|
115
|
+
const tool = classify(toolRows.filter(managed), 'tool');
|
|
116
|
+
const generic = classify(genericRows.filter(managed), 'generic');
|
|
117
|
+
return {
|
|
118
|
+
tool: tool.supported,
|
|
119
|
+
generic: generic.supported,
|
|
120
|
+
unsupported: [
|
|
121
|
+
...tool.unsupported.map((row) => ({ table: 'tool', row })),
|
|
122
|
+
...generic.unsupported.map((row) => ({ table: 'generic', row })),
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every live policy name in the org, whatever its origin, so the plan can
|
|
129
|
+
* refuse a create or rename that the server would reject as a duplicate.
|
|
130
|
+
* Map name -> { table, id, code_pack, code_slug }.
|
|
131
|
+
*/
|
|
132
|
+
async function fetchAllNames() {
|
|
133
|
+
const [toolRows, genericRows] = await Promise.all([fetchAllToolRows(), fetchAllGenericRows()]);
|
|
134
|
+
const names = new Map();
|
|
135
|
+
const add = (row, table) => {
|
|
136
|
+
if (row && typeof row.name === 'string') names.set(row.name, { table, id: row.id, code_pack: row.code_pack || null, code_slug: row.code_slug || null });
|
|
137
|
+
};
|
|
138
|
+
toolRows.forEach((r) => add(r, 'tool'));
|
|
139
|
+
genericRows.forEach((r) => add(r, 'generic'));
|
|
140
|
+
return names;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every policy in the org, grouped by `code_pack`. Rows that no pack manages
|
|
145
|
+
* (console-made, or without an identity) are returned as `unmanaged`.
|
|
146
|
+
*/
|
|
147
|
+
async function fetchAllGrouped() {
|
|
148
|
+
const [toolRows, genericRows] = await Promise.all([fetchAllToolRows(), fetchAllGenericRows()]);
|
|
149
|
+
requireCodeIdentitySupport([...toolRows, ...genericRows]);
|
|
150
|
+
const packs = new Map();
|
|
151
|
+
const unmanaged = [];
|
|
152
|
+
const add = (row, table) => {
|
|
153
|
+
const pack = row.origin === CODE_ORIGIN && row.code_slug ? row.code_pack : null;
|
|
154
|
+
if (!pack) { unmanaged.push({ table, row }); return; }
|
|
155
|
+
if (!packs.has(pack)) packs.set(pack, { tool: [], generic: [], unsupported: [] });
|
|
156
|
+
const bucket = packs.get(pack);
|
|
157
|
+
const { supported } = classify([row], table);
|
|
158
|
+
if (supported.length) bucket[table].push(row);
|
|
159
|
+
else bucket.unsupported.push({ table, row });
|
|
160
|
+
};
|
|
161
|
+
toolRows.forEach((r) => add(r, 'tool'));
|
|
162
|
+
genericRows.forEach((r) => add(r, 'generic'));
|
|
163
|
+
return { packs, unmanaged };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Rows (either table) whose name is exactly `name`, each tagged with whether
|
|
168
|
+
* this CLI version manages its type (`supported`).
|
|
169
|
+
*/
|
|
170
|
+
async function findByExactName(name) {
|
|
171
|
+
const [toolRows, genericRows] = await Promise.all([fetchAllToolRows({ search: name }), fetchAllGenericRows()]);
|
|
172
|
+
const tag = (row, table) => ({ table, row, supported: classify([row], table).supported.length === 1 });
|
|
173
|
+
return [
|
|
174
|
+
...toolRows.filter((r) => r.name === name).map((row) => tag(row, 'tool')),
|
|
175
|
+
...genericRows.filter((r) => r.name === name).map((row) => tag(row, 'generic')),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pathFor(table, id) {
|
|
180
|
+
const base = table === 'tool' ? TOOL_PATH : GENERIC_PATH;
|
|
181
|
+
return id === undefined ? base : `${base}${id}/`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function createRow(table, body) {
|
|
185
|
+
return api.post(pathFor(table), { body: { ...body, origin: CODE_ORIGIN } });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function updateRow(table, id, body) {
|
|
189
|
+
return api.put(pathFor(table, id), { body: { ...body, origin: CODE_ORIGIN } });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function deleteRow(table, id) {
|
|
193
|
+
return api.del(pathFor(table, id), { query: { origin: CODE_ORIGIN } });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Id of a freshly created row. The generic endpoint wraps in {policy}, the
|
|
198
|
+
* command-policies endpoint returns the row unwrapped today; both envelopes
|
|
199
|
+
* the rest of the CLI tolerates ({command_policy}, {policy}) are handled too.
|
|
200
|
+
*/
|
|
201
|
+
function createdId(table, response) {
|
|
202
|
+
if (!response || typeof response !== 'object') return null;
|
|
203
|
+
let row = response;
|
|
204
|
+
if (row.command_policy && typeof row.command_policy === 'object') row = row.command_policy;
|
|
205
|
+
else if (row.policy && typeof row.policy === 'object' && row.id === undefined) row = row.policy;
|
|
206
|
+
return row.id !== undefined && row.id !== null ? row.id : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace a policy that cannot be patched in place. The new policy is created
|
|
211
|
+
* first under a temporary name and identity, so a failed create leaves the old
|
|
212
|
+
* one (and its enforcement) untouched; the old row is deleted only after that
|
|
213
|
+
* succeeds, and the new row then takes the final name and identity.
|
|
214
|
+
*/
|
|
215
|
+
function tempSuffix() {
|
|
216
|
+
return `-replacing-${Date.now().toString(36)}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function tempName(finalName, suffix = tempSuffix()) {
|
|
220
|
+
return `${String(finalName).slice(0, 255 - suffix.length)}${suffix}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Still inside the pack (same code_pack), so a stranded row shows up as an orphan on the next apply. */
|
|
224
|
+
function tempSlug(finalSlug, suffix = tempSuffix()) {
|
|
225
|
+
if (!finalSlug) return undefined;
|
|
226
|
+
return `${finalSlug.slice(0, 64 - suffix.length)}${suffix}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function replaceRow(table, oldRow, body) {
|
|
230
|
+
const suffix = tempSuffix();
|
|
231
|
+
const tmpName = tempName(body.name, suffix);
|
|
232
|
+
const tmpSlug = tempSlug(body.code_slug, suffix);
|
|
233
|
+
const created = await createRow(table, { ...body, name: tmpName, ...(tmpSlug ? { code_slug: tmpSlug } : {}) });
|
|
234
|
+
const newId = createdId(table, created);
|
|
235
|
+
if (newId === null) {
|
|
236
|
+
throw new Error(`created "${tmpName}" but the server returned no id; rename it to "${body.name}" by hand`);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await deleteRow(table, oldRow.id);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
// Roll back: the old policy is still there, so remove the duplicate.
|
|
242
|
+
try {
|
|
243
|
+
await deleteRow(table, newId);
|
|
244
|
+
} catch (rollbackErr) {
|
|
245
|
+
throw new Error(`deleting the old policy failed (${err.message}) and the replacement "${tmpName}" could not be removed (${rollbackErr.message}); it will show as an orphan on the next apply`);
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const final = { name: body.name };
|
|
251
|
+
if (body.code_slug) { final.code_pack = body.code_pack; final.code_slug = body.code_slug; }
|
|
252
|
+
await updateRow(table, newId, final);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
throw new Error(`old policy deleted and the new one is live as "${tmpName}", but renaming it to "${body.name}" failed: ${err.message}. Re-run apply: it will create "${body.name}" and list "${tmpName}" as an orphan to prune`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Execute plan operations in order. Never throws for a single failed op:
|
|
260
|
+
* every op gets a result {op, status: 'ok'|'failed'|'skipped', error}.
|
|
261
|
+
* `onResult` is called after each op so the caller can stream progress.
|
|
262
|
+
*/
|
|
263
|
+
async function writeOps(ops, { onResult } = {}) {
|
|
264
|
+
const results = [];
|
|
265
|
+
for (const op of ops) {
|
|
266
|
+
const result = { op, status: 'skipped', error: null };
|
|
267
|
+
try {
|
|
268
|
+
if (op.op === 'create') {
|
|
269
|
+
await createRow(op.table, op.body);
|
|
270
|
+
result.status = 'ok';
|
|
271
|
+
} else if (op.op === 'update') {
|
|
272
|
+
await updateRow(op.table, op.row.id, op.body);
|
|
273
|
+
result.status = 'ok';
|
|
274
|
+
} else if (op.op === 'replace') {
|
|
275
|
+
await replaceRow(op.table, op.row, op.body);
|
|
276
|
+
result.status = 'ok';
|
|
277
|
+
} else if (op.op === 'delete') {
|
|
278
|
+
await deleteRow(op.table, op.row.id);
|
|
279
|
+
result.status = 'ok';
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
result.status = 'failed';
|
|
283
|
+
result.error = err && err.message ? err.message : String(err);
|
|
284
|
+
}
|
|
285
|
+
results.push(result);
|
|
286
|
+
if (onResult) onResult(result);
|
|
287
|
+
}
|
|
288
|
+
return results;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
TOOL_PATH,
|
|
293
|
+
GENERIC_PATH,
|
|
294
|
+
loadReference,
|
|
295
|
+
resetReference,
|
|
296
|
+
fetchManaged,
|
|
297
|
+
fetchAllNames,
|
|
298
|
+
fetchAllGrouped,
|
|
299
|
+
findByExactName,
|
|
300
|
+
createRow,
|
|
301
|
+
updateRow,
|
|
302
|
+
deleteRow,
|
|
303
|
+
replaceRow,
|
|
304
|
+
tempName,
|
|
305
|
+
tempSlug,
|
|
306
|
+
createdId,
|
|
307
|
+
writeOps,
|
|
308
|
+
};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// PolicyPack file format: parsing, validation and defaults merging.
|
|
2
|
+
//
|
|
3
|
+
// One file is one pack. A pack is a versioned, org-owned bundle of one or more
|
|
4
|
+
// policies. Every policy in the pack is materialised on the server as an
|
|
5
|
+
// ordinary Cost / Security / Model policy (/api/v1/policies/) or Tool policy
|
|
6
|
+
// (/api/v1/command-policies/) whose name is `<pack>/<id>`. That prefix is the
|
|
7
|
+
// only tag that separates code-managed rows from console-made ones, so
|
|
8
|
+
// everything in this module is pure: no network, no ids, just names.
|
|
9
|
+
//
|
|
10
|
+
// Validation is strict on purpose. A typo must never silently become an
|
|
11
|
+
// unmanaged field, so unknown keys fail at every level. `config` is the one
|
|
12
|
+
// exception: it is passed to the server verbatim and never inspected here.
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const YAML = require('yaml');
|
|
16
|
+
|
|
17
|
+
const API_VERSION = 'unbound.security/v1';
|
|
18
|
+
const KIND = 'PolicyPack';
|
|
19
|
+
|
|
20
|
+
const POLICY_KINDS = ['tool', 'security', 'model', 'cost'];
|
|
21
|
+
const GENERIC_KINDS = ['security', 'model', 'cost'];
|
|
22
|
+
const TOOL_TYPES = ['TERMINAL_COMMAND', 'MCP_TOOL'];
|
|
23
|
+
const TOOL_ACTIONS = ['AUDIT', 'BLOCK', 'WARN', 'REQUIRE_SLACK_APPROVAL'];
|
|
24
|
+
const GUARDRAIL_ACTIONS = ['BLOCK', 'REDACT', 'ROUTE', 'AUDIT'];
|
|
25
|
+
const MCP_ACTION_TYPES = ['read', 'write', 'destructive'];
|
|
26
|
+
|
|
27
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
28
|
+
|
|
29
|
+
const PACK_KEYS = ['apiVersion', 'kind', 'metadata', 'defaults', 'policies'];
|
|
30
|
+
const METADATA_KEYS = ['name', 'version', 'description', 'owner', 'labels'];
|
|
31
|
+
const DEFAULTS_KEYS = ['enabled', 'scope', 'labels'];
|
|
32
|
+
const SCOPE_KEYS = ['userGroups', 'toolTypes'];
|
|
33
|
+
const COMMON_KEYS = ['id', 'name', 'kind', 'enabled', 'scope', 'config'];
|
|
34
|
+
// description exists on tool policies only; the generic policy API has no such field.
|
|
35
|
+
const TOOL_ONLY_KEYS = ['type', 'action', 'description', 'customMessage', 'commandFamily', 'mcp'];
|
|
36
|
+
const GENERIC_ONLY_KEYS = ['priority', 'labels'];
|
|
37
|
+
const SECURITY_ONLY_KEYS = ['guardrails'];
|
|
38
|
+
const MODEL_ONLY_KEYS = ['models'];
|
|
39
|
+
const MCP_KEYS = ['server', 'tool', 'tools', 'actionType'];
|
|
40
|
+
const GUARDRAIL_KEYS = ['name', 'action', 'threshold', 'entities', 'routeModel', 'enabled'];
|
|
41
|
+
const MODELS_KEYS = ['allowAll', 'allowed', 'excluded'];
|
|
42
|
+
|
|
43
|
+
class PackError extends Error {
|
|
44
|
+
constructor(message, { file } = {}) {
|
|
45
|
+
super(file ? `${file}: ${message}` : message);
|
|
46
|
+
this.name = 'PackError';
|
|
47
|
+
this.exitCode = 2;
|
|
48
|
+
this.file = file;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPlainObject(v) {
|
|
53
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isStringArray(v) {
|
|
57
|
+
return Array.isArray(v) && v.every((s) => typeof s === 'string' && s.trim() !== '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse pack text. JSON when the filename ends in .json, YAML otherwise.
|
|
62
|
+
* YAML is a superset of JSON, so a .yaml file holding JSON also parses.
|
|
63
|
+
*/
|
|
64
|
+
function parsePackText(text, filename = 'pack') {
|
|
65
|
+
const ext = path.extname(filename).toLowerCase();
|
|
66
|
+
try {
|
|
67
|
+
if (ext === '.json') return JSON.parse(text);
|
|
68
|
+
// Single-document YAML only. A multi-document stream is rejected so one
|
|
69
|
+
// file stays one pack.
|
|
70
|
+
const docs = YAML.parseAllDocuments(text, { prettyErrors: true });
|
|
71
|
+
if (docs.length !== 1) {
|
|
72
|
+
throw new Error(`expected exactly one YAML document, found ${docs.length}`);
|
|
73
|
+
}
|
|
74
|
+
const doc = docs[0];
|
|
75
|
+
if (doc.errors && doc.errors.length) throw doc.errors[0];
|
|
76
|
+
return doc.toJS();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new PackError(`could not parse: ${err.message}`, { file: filename });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rejectUnknownKeys(obj, allowed, where, file) {
|
|
83
|
+
const unknown = Object.keys(obj).filter((k) => !allowed.includes(k));
|
|
84
|
+
if (unknown.length) {
|
|
85
|
+
throw new PackError(
|
|
86
|
+
`${where}: unknown key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')}. Allowed: ${allowed.join(', ')}`,
|
|
87
|
+
{ file }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validateScope(scope, where, file, { allowToolTypes }) {
|
|
93
|
+
if (scope === undefined) return undefined;
|
|
94
|
+
if (!isPlainObject(scope)) throw new PackError(`${where}.scope must be an object`, { file });
|
|
95
|
+
rejectUnknownKeys(scope, SCOPE_KEYS, `${where}.scope`, file);
|
|
96
|
+
if (scope.userGroups !== undefined && !isStringArray(scope.userGroups)) {
|
|
97
|
+
throw new PackError(`${where}.scope.userGroups must be a list of user group names`, { file });
|
|
98
|
+
}
|
|
99
|
+
if (scope.toolTypes !== undefined) {
|
|
100
|
+
if (!allowToolTypes) throw new PackError(`${where}.scope.toolTypes applies to security, model and cost policies only`, { file });
|
|
101
|
+
if (!isStringArray(scope.toolTypes)) throw new PackError(`${where}.scope.toolTypes must be a list of tool type names`, { file });
|
|
102
|
+
}
|
|
103
|
+
return scope;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateMetadata(metadata, file) {
|
|
107
|
+
if (!isPlainObject(metadata)) throw new PackError('metadata must be an object', { file });
|
|
108
|
+
rejectUnknownKeys(metadata, METADATA_KEYS, 'metadata', file);
|
|
109
|
+
const { name, version, description, owner, labels } = metadata;
|
|
110
|
+
if (typeof name !== 'string' || !SLUG_RE.test(name)) {
|
|
111
|
+
throw new PackError('metadata.name is required and must match [a-z0-9][a-z0-9-]{0,63}', { file });
|
|
112
|
+
}
|
|
113
|
+
if (typeof version !== 'string' || version.trim() === '') {
|
|
114
|
+
throw new PackError('metadata.version is required (a string such as "1.0.0")', { file });
|
|
115
|
+
}
|
|
116
|
+
if (description !== undefined && typeof description !== 'string') throw new PackError('metadata.description must be a string', { file });
|
|
117
|
+
if (owner !== undefined && typeof owner !== 'string') throw new PackError('metadata.owner must be a string', { file });
|
|
118
|
+
if (labels !== undefined) {
|
|
119
|
+
if (!isPlainObject(labels) || !Object.values(labels).every((v) => typeof v === 'string')) {
|
|
120
|
+
throw new PackError('metadata.labels must be an object of string values', { file });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { name, version: version.trim(), description, owner, labels };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateDefaults(defaults, file) {
|
|
127
|
+
if (defaults === undefined) return {};
|
|
128
|
+
if (!isPlainObject(defaults)) throw new PackError('defaults must be an object', { file });
|
|
129
|
+
rejectUnknownKeys(defaults, DEFAULTS_KEYS, 'defaults', file);
|
|
130
|
+
if (defaults.enabled !== undefined && typeof defaults.enabled !== 'boolean') throw new PackError('defaults.enabled must be true or false', { file });
|
|
131
|
+
if (defaults.labels !== undefined && !isStringArray(defaults.labels)) throw new PackError('defaults.labels must be a list of label names', { file });
|
|
132
|
+
validateScope(defaults.scope, 'defaults', file, { allowToolTypes: true });
|
|
133
|
+
return defaults;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Shallow merge of defaults into a policy; scope merges per sub-key. */
|
|
137
|
+
function mergeDefaults(policy, defaults) {
|
|
138
|
+
const out = { ...policy };
|
|
139
|
+
if (out.enabled === undefined && defaults.enabled !== undefined) out.enabled = defaults.enabled;
|
|
140
|
+
if (out.labels === undefined && defaults.labels !== undefined && GENERIC_KINDS.includes(out.kind)) out.labels = [...defaults.labels];
|
|
141
|
+
if (defaults.scope) {
|
|
142
|
+
const scope = { ...(out.scope || {}) };
|
|
143
|
+
if (scope.userGroups === undefined && defaults.scope.userGroups !== undefined) scope.userGroups = [...defaults.scope.userGroups];
|
|
144
|
+
if (scope.toolTypes === undefined && defaults.scope.toolTypes !== undefined && GENERIC_KINDS.includes(out.kind)) scope.toolTypes = [...defaults.scope.toolTypes];
|
|
145
|
+
if (Object.keys(scope).length) out.scope = scope;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateMcp(mcp, where, file) {
|
|
151
|
+
if (!isPlainObject(mcp)) throw new PackError(`${where}.mcp is required for MCP_TOOL policies and must be an object`, { file });
|
|
152
|
+
rejectUnknownKeys(mcp, MCP_KEYS, `${where}.mcp`, file);
|
|
153
|
+
if (typeof mcp.server !== 'string' || mcp.server.trim() === '') throw new PackError(`${where}.mcp.server is required`, { file });
|
|
154
|
+
const selectors = ['tool', 'tools', 'actionType'].filter((k) => mcp[k] !== undefined);
|
|
155
|
+
if (selectors.length !== 1) {
|
|
156
|
+
throw new PackError(`${where}.mcp needs exactly one of tool, tools or actionType (found ${selectors.length ? selectors.join(', ') : 'none'})`, { file });
|
|
157
|
+
}
|
|
158
|
+
if (mcp.tool !== undefined && (typeof mcp.tool !== 'string' || mcp.tool.trim() === '')) throw new PackError(`${where}.mcp.tool must be a tool name`, { file });
|
|
159
|
+
if (mcp.tools !== undefined && (!isStringArray(mcp.tools) || mcp.tools.length === 0)) throw new PackError(`${where}.mcp.tools must be a non-empty list of tool names`, { file });
|
|
160
|
+
if (mcp.actionType !== undefined && !MCP_ACTION_TYPES.includes(mcp.actionType)) {
|
|
161
|
+
throw new PackError(`${where}.mcp.actionType must be one of: ${MCP_ACTION_TYPES.join(', ')}`, { file });
|
|
162
|
+
}
|
|
163
|
+
return mcp;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateToolPolicy(p, where, file) {
|
|
167
|
+
rejectUnknownKeys(p, [...COMMON_KEYS, ...TOOL_ONLY_KEYS], where, file);
|
|
168
|
+
if (!TOOL_TYPES.includes(p.type)) throw new PackError(`${where}.type must be one of: ${TOOL_TYPES.join(', ')}`, { file });
|
|
169
|
+
if (!TOOL_ACTIONS.includes(p.action)) throw new PackError(`${where}.action must be one of: ${TOOL_ACTIONS.join(', ')}`, { file });
|
|
170
|
+
if (p.customMessage !== undefined && typeof p.customMessage !== 'string') throw new PackError(`${where}.customMessage must be a string`, { file });
|
|
171
|
+
// The server accepts an empty message, and export must be able to round-trip
|
|
172
|
+
// such a policy, so the key has to be present but may be an empty string.
|
|
173
|
+
if ((p.action === 'BLOCK' || p.action === 'WARN') && typeof p.customMessage !== 'string') {
|
|
174
|
+
throw new PackError(`${where}.customMessage is required when action is BLOCK or WARN (an empty string is allowed)`, { file });
|
|
175
|
+
}
|
|
176
|
+
if (p.config !== undefined && !isPlainObject(p.config)) throw new PackError(`${where}.config must be an object`, { file });
|
|
177
|
+
if (p.type === 'TERMINAL_COMMAND') {
|
|
178
|
+
if (typeof p.commandFamily !== 'string' || p.commandFamily.trim() === '') throw new PackError(`${where}.commandFamily is required for TERMINAL_COMMAND policies`, { file });
|
|
179
|
+
if (!p.config || Object.keys(p.config).length === 0) throw new PackError(`${where}.config is required for TERMINAL_COMMAND policies (field -> pattern)`, { file });
|
|
180
|
+
if (p.mcp !== undefined) throw new PackError(`${where}.mcp applies to MCP_TOOL policies only`, { file });
|
|
181
|
+
} else {
|
|
182
|
+
if (p.commandFamily !== undefined) throw new PackError(`${where}.commandFamily applies to TERMINAL_COMMAND policies only`, { file });
|
|
183
|
+
validateMcp(p.mcp, where, file);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validateGuardrails(guardrails, where, file) {
|
|
188
|
+
if (!Array.isArray(guardrails) || guardrails.length === 0) throw new PackError(`${where}.guardrails must be a non-empty list`, { file });
|
|
189
|
+
guardrails.forEach((g, i) => {
|
|
190
|
+
const gw = `${where}.guardrails[${i}]`;
|
|
191
|
+
if (!isPlainObject(g)) throw new PackError(`${gw} must be an object`, { file });
|
|
192
|
+
rejectUnknownKeys(g, GUARDRAIL_KEYS, gw, file);
|
|
193
|
+
if (typeof g.name !== 'string' || g.name.trim() === '') throw new PackError(`${gw}.name is required`, { file });
|
|
194
|
+
if (!GUARDRAIL_ACTIONS.includes(g.action)) throw new PackError(`${gw}.action must be one of: ${GUARDRAIL_ACTIONS.join(', ')}`, { file });
|
|
195
|
+
if (g.threshold !== undefined && !(Number.isInteger(g.threshold) && g.threshold >= 1 && g.threshold <= 100)) {
|
|
196
|
+
throw new PackError(`${gw}.threshold must be an integer from 1 to 100`, { file });
|
|
197
|
+
}
|
|
198
|
+
if (g.entities !== undefined && !isStringArray(g.entities)) throw new PackError(`${gw}.entities must be a list of entity names`, { file });
|
|
199
|
+
if (g.routeModel !== undefined && typeof g.routeModel !== 'string') throw new PackError(`${gw}.routeModel must be a model name`, { file });
|
|
200
|
+
if (g.enabled !== undefined && typeof g.enabled !== 'boolean') throw new PackError(`${gw}.enabled must be true or false`, { file });
|
|
201
|
+
if (g.action === 'ROUTE' && !g.routeModel) throw new PackError(`${gw}.routeModel is required when action is ROUTE`, { file });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateModels(models, where, file) {
|
|
206
|
+
if (!isPlainObject(models)) throw new PackError(`${where}.models must be an object`, { file });
|
|
207
|
+
rejectUnknownKeys(models, MODELS_KEYS, `${where}.models`, file);
|
|
208
|
+
if (models.allowAll !== undefined && typeof models.allowAll !== 'boolean') throw new PackError(`${where}.models.allowAll must be true or false`, { file });
|
|
209
|
+
if (models.allowed !== undefined && !isStringArray(models.allowed)) throw new PackError(`${where}.models.allowed must be a list of model names`, { file });
|
|
210
|
+
if (models.excluded !== undefined && !isStringArray(models.excluded)) throw new PackError(`${where}.models.excluded must be a list of model names`, { file });
|
|
211
|
+
if (models.allowed && models.allowed.length && (models.allowAll || models.excluded)) {
|
|
212
|
+
throw new PackError(`${where}.models.allowed cannot be combined with allowAll or excluded`, { file });
|
|
213
|
+
}
|
|
214
|
+
if (!models.allowAll && !(models.allowed && models.allowed.length) && !(models.excluded && models.excluded.length)) {
|
|
215
|
+
throw new PackError(`${where}.models needs allowAll: true, an allowed list, or an excluded list`, { file });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateGenericPolicy(p, where, file) {
|
|
220
|
+
const allowed = [...COMMON_KEYS, ...GENERIC_ONLY_KEYS];
|
|
221
|
+
if (p.kind === 'security') allowed.push(...SECURITY_ONLY_KEYS);
|
|
222
|
+
if (p.kind === 'model') allowed.push(...MODEL_ONLY_KEYS);
|
|
223
|
+
rejectUnknownKeys(p, allowed, where, file);
|
|
224
|
+
if (p.priority !== undefined && !Number.isInteger(p.priority)) throw new PackError(`${where}.priority must be an integer`, { file });
|
|
225
|
+
if (p.labels !== undefined && !isStringArray(p.labels)) throw new PackError(`${where}.labels must be a list of label names`, { file });
|
|
226
|
+
if (p.config !== undefined && !isPlainObject(p.config)) throw new PackError(`${where}.config must be an object`, { file });
|
|
227
|
+
|
|
228
|
+
if (p.kind === 'security') {
|
|
229
|
+
if (p.guardrails !== undefined) {
|
|
230
|
+
validateGuardrails(p.guardrails, where, file);
|
|
231
|
+
if (p.config && p.config.guardrails !== undefined) throw new PackError(`${where}: use either guardrails or config.guardrails, not both`, { file });
|
|
232
|
+
} else if (!p.config || Object.keys(p.config).length === 0) {
|
|
233
|
+
throw new PackError(`${where}: a security policy needs guardrails or a config`, { file });
|
|
234
|
+
}
|
|
235
|
+
} else if (p.kind === 'model') {
|
|
236
|
+
if (p.models !== undefined) {
|
|
237
|
+
validateModels(p.models, where, file);
|
|
238
|
+
if (p.config && ['support_all_models', 'allowed_model_ids', 'excluded_model_ids'].some((k) => k in p.config)) {
|
|
239
|
+
throw new PackError(`${where}: use either models or config.*_model_ids, not both`, { file });
|
|
240
|
+
}
|
|
241
|
+
} else if (!p.config || Object.keys(p.config).length === 0) {
|
|
242
|
+
throw new PackError(`${where}: a model policy needs models or a config`, { file });
|
|
243
|
+
}
|
|
244
|
+
} else if (p.kind === 'cost') {
|
|
245
|
+
if (!p.config || Object.keys(p.config).length === 0) throw new PackError(`${where}: a cost policy needs a config (for example monthly_budget)`, { file });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validate a parsed pack document. Returns a normalised pack:
|
|
251
|
+
* { name, version, description, owner, labels, policies: [policy] } where
|
|
252
|
+
* each policy has defaults merged, `serverName` = its `name` or its `id`, and
|
|
253
|
+
* `enabled` resolved (default true).
|
|
254
|
+
*/
|
|
255
|
+
function validatePack(doc, file) {
|
|
256
|
+
if (!isPlainObject(doc)) throw new PackError('the document must be an object', { file });
|
|
257
|
+
rejectUnknownKeys(doc, PACK_KEYS, 'top level', file);
|
|
258
|
+
if (doc.apiVersion !== API_VERSION) throw new PackError(`apiVersion must be "${API_VERSION}"`, { file });
|
|
259
|
+
if (doc.kind !== KIND) throw new PackError(`kind must be "${KIND}"`, { file });
|
|
260
|
+
|
|
261
|
+
const metadata = validateMetadata(doc.metadata, file);
|
|
262
|
+
const defaults = validateDefaults(doc.defaults, file);
|
|
263
|
+
|
|
264
|
+
if (!Array.isArray(doc.policies) || doc.policies.length === 0) throw new PackError('policies must be a non-empty list', { file });
|
|
265
|
+
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
const seenNames = new Set();
|
|
268
|
+
const policies = doc.policies.map((raw, i) => {
|
|
269
|
+
const where = `policies[${i}]`;
|
|
270
|
+
if (!isPlainObject(raw)) throw new PackError(`${where} must be an object`, { file });
|
|
271
|
+
if (typeof raw.id !== 'string' || !SLUG_RE.test(raw.id)) throw new PackError(`${where}.id is required and must match [a-z0-9][a-z0-9-]{0,63}`, { file });
|
|
272
|
+
if (seen.has(raw.id)) throw new PackError(`${where}.id "${raw.id}" is used twice in this pack`, { file });
|
|
273
|
+
seen.add(raw.id);
|
|
274
|
+
if (raw.name !== undefined && (typeof raw.name !== 'string' || !raw.name.trim() || raw.name.length > 255)) {
|
|
275
|
+
throw new PackError(`${where}.name must be a non-empty string of at most 255 characters`, { file });
|
|
276
|
+
}
|
|
277
|
+
const serverName = raw.name !== undefined ? raw.name.trim() : raw.id;
|
|
278
|
+
if (seenNames.has(serverName)) throw new PackError(`${where}: the name "${serverName}" is used twice in this pack`, { file });
|
|
279
|
+
seenNames.add(serverName);
|
|
280
|
+
if (!POLICY_KINDS.includes(raw.kind)) throw new PackError(`${where}.kind must be one of: ${POLICY_KINDS.join(', ')}`, { file });
|
|
281
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') throw new PackError(`${where}.enabled must be true or false`, { file });
|
|
282
|
+
if (raw.description !== undefined && typeof raw.description !== 'string') throw new PackError(`${where}.description must be a string`, { file });
|
|
283
|
+
|
|
284
|
+
const label = `${where} (${raw.id})`;
|
|
285
|
+
if (raw.kind === 'tool') validateToolPolicy(raw, label, file);
|
|
286
|
+
else validateGenericPolicy(raw, label, file);
|
|
287
|
+
validateScope(raw.scope, label, file, { allowToolTypes: raw.kind !== 'tool' });
|
|
288
|
+
|
|
289
|
+
const merged = mergeDefaults(raw, defaults);
|
|
290
|
+
return {
|
|
291
|
+
...merged,
|
|
292
|
+
enabled: merged.enabled === undefined ? true : merged.enabled,
|
|
293
|
+
// The policy's display name on the server: free text, defaults to the id.
|
|
294
|
+
// Identity is carried separately as code_pack + code_slug.
|
|
295
|
+
serverName,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return { ...metadata, policies };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Turn a free-form console policy name into a pack policy id. */
|
|
303
|
+
function slugify(name) {
|
|
304
|
+
const slug = String(name || '')
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
307
|
+
.replace(/^-+|-+$/g, '')
|
|
308
|
+
.slice(0, 64)
|
|
309
|
+
.replace(/-+$/g, '');
|
|
310
|
+
return SLUG_RE.test(slug) ? slug : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
API_VERSION,
|
|
315
|
+
KIND,
|
|
316
|
+
POLICY_KINDS,
|
|
317
|
+
GENERIC_KINDS,
|
|
318
|
+
TOOL_TYPES,
|
|
319
|
+
TOOL_ACTIONS,
|
|
320
|
+
GUARDRAIL_ACTIONS,
|
|
321
|
+
MCP_ACTION_TYPES,
|
|
322
|
+
SLUG_RE,
|
|
323
|
+
PackError,
|
|
324
|
+
parsePackText,
|
|
325
|
+
validatePack,
|
|
326
|
+
mergeDefaults,
|
|
327
|
+
slugify,
|
|
328
|
+
};
|
|
@@ -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
|
+
});
|