unbound-cli 1.18.0 → 1.20.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 +63 -0
- package/package.json +3 -2
- package/src/auth-pages.js +3 -2
- package/src/auth.js +12 -5
- package/src/commands/login.js +24 -2
- package/src/commands/pack.js +566 -0
- package/src/commands/status.js +9 -1
- package/src/index.js +11 -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/login-scope.js +85 -0
- package/test/auth-server-leak.test.js +8 -1
- package/test/login-scope-callback.test.js +79 -0
- package/test/login-scope.test.js +51 -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,566 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const output = require('../output');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const { PackError, parsePackText, validatePack, slugify, SLUG_RE } = require('../lib/pack-schema');
|
|
7
|
+
const { toDesired, buildPlan, summarize, stable } = require('../lib/pack-plan');
|
|
8
|
+
const { liveToPack, stringifyPack } = require('../lib/pack-export');
|
|
9
|
+
const remote = require('../lib/pack-remote');
|
|
10
|
+
|
|
11
|
+
const DOCS_PACKS = 'https://docs.getunbound.ai/policies/policy-as-code';
|
|
12
|
+
const PACK_EXTENSIONS = ['.yaml', '.yml', '.json'];
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Helpers
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
function requireLogin() {
|
|
19
|
+
if (!config.isLoggedIn()) {
|
|
20
|
+
throw new Error('Not logged in. Run `unbound login` first.');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fail(err) {
|
|
25
|
+
output.error(err.message);
|
|
26
|
+
process.exitCode = err.exitCode || 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Like utils.confirm, but the prompt is written to stderr: stdout of `adopt`,
|
|
31
|
+
* `export` and `--json` runs carries only the pack file or the JSON document,
|
|
32
|
+
* so a redirected stdout must never receive prompt text.
|
|
33
|
+
*/
|
|
34
|
+
function confirm(message, { stdin = process.stdin } = {}) {
|
|
35
|
+
if (!stdin.isTTY) {
|
|
36
|
+
// CI or a pipe: there is nobody to answer, so fail closed instead of
|
|
37
|
+
// hanging on a prompt or silently exiting 0 when stdin closes.
|
|
38
|
+
const err = new Error(`${message} Confirmation needed but stdin is not interactive; pass --yes.`);
|
|
39
|
+
err.exitCode = 1;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const rl = readline.createInterface({ input: stdin, output: process.stderr });
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
46
|
+
rl.on('close', () => done(false));
|
|
47
|
+
rl.question(`${message} (y/N) `, (answer) => {
|
|
48
|
+
// Settle before close(): close() emits 'close' synchronously, which would
|
|
49
|
+
// otherwise resolve false first and swallow the answer.
|
|
50
|
+
done(String(answer).trim().toLowerCase() === 'y');
|
|
51
|
+
rl.close();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collect(value, prev = []) {
|
|
57
|
+
return [...prev, value];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read one pack file, or every pack file in a directory (sorted by name). */
|
|
61
|
+
function loadPackFiles(target) {
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.statSync(target);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new PackError(`no such file or directory: ${target}`);
|
|
67
|
+
}
|
|
68
|
+
let files;
|
|
69
|
+
if (stat.isDirectory()) {
|
|
70
|
+
files = fs.readdirSync(target)
|
|
71
|
+
.filter((f) => PACK_EXTENSIONS.includes(path.extname(f).toLowerCase()))
|
|
72
|
+
.sort()
|
|
73
|
+
.map((f) => path.join(target, f));
|
|
74
|
+
if (!files.length) throw new PackError(`no .yaml, .yml or .json pack files in ${target}`);
|
|
75
|
+
} else {
|
|
76
|
+
files = [target];
|
|
77
|
+
}
|
|
78
|
+
const seen = new Map();
|
|
79
|
+
return files.map((file) => {
|
|
80
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
81
|
+
const pack = validatePack(parsePackText(text, file), file);
|
|
82
|
+
if (seen.has(pack.name)) {
|
|
83
|
+
throw new PackError(`pack "${pack.name}" is defined twice: ${seen.get(pack.name)} and ${file}`);
|
|
84
|
+
}
|
|
85
|
+
seen.set(pack.name, file);
|
|
86
|
+
return { file, pack };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function short(value) {
|
|
91
|
+
const text = stable(value);
|
|
92
|
+
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The pack wants the policy on but the server has it off. Either someone
|
|
97
|
+
* switched it off in the console (the one change the console still allows on a
|
|
98
|
+
* managed row) or the file was just edited to turn it on; the plan cannot tell
|
|
99
|
+
* which, so the wording names both.
|
|
100
|
+
*/
|
|
101
|
+
function offOnServer(change) {
|
|
102
|
+
return change.field === 'enabled' && change.from === false && change.to === true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function changeText(op) {
|
|
106
|
+
if (op.op === 'conflict' || op.op === 'orphan' || op.op === 'delete') return op.reason || '';
|
|
107
|
+
if (op.op === 'replace') return `${op.reason}; recreated`;
|
|
108
|
+
if (!op.changes || !op.changes.length) return '';
|
|
109
|
+
return op.changes.map((c) => {
|
|
110
|
+
if (c.field === 'identity') return op.reason;
|
|
111
|
+
if (offOnServer(c)) return 'enabled: false -> true (off on the server, on in the pack)';
|
|
112
|
+
if (c.field === 'origin') return `origin: ${short(c.from)} -> ${short(c.to)} (adopt: console becomes enable/disable only)`;
|
|
113
|
+
return `${c.field}: ${short(c.from)} -> ${short(c.to)}`;
|
|
114
|
+
}).join('; ');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ACTION_LABEL = {
|
|
118
|
+
create: 'create',
|
|
119
|
+
update: 'update',
|
|
120
|
+
replace: 'replace',
|
|
121
|
+
delete: 'delete',
|
|
122
|
+
orphan: 'keep',
|
|
123
|
+
unchanged: 'ok',
|
|
124
|
+
conflict: 'CONFLICT',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function printPlan({ file, pack, ops, live }) {
|
|
128
|
+
console.log('');
|
|
129
|
+
output.info(`Pack ${pack.name} v${pack.version} (${file})`);
|
|
130
|
+
output.table(ops, [
|
|
131
|
+
{ key: 'op', header: 'Action', format: (v) => ACTION_LABEL[v] || v },
|
|
132
|
+
{ key: 'kind', header: 'Kind' },
|
|
133
|
+
{ key: 'id', header: 'Id' },
|
|
134
|
+
{ key: 'serverName', header: 'Name' },
|
|
135
|
+
{ key: 'changes', header: 'Details', format: (_v, row) => changeText(row) },
|
|
136
|
+
]);
|
|
137
|
+
for (const { row, table } of live.unsupported || []) {
|
|
138
|
+
output.warn(`${row.name}: ${table === 'tool' ? row.policy_type : row.type} policies are not managed by this CLI version; left untouched.`);
|
|
139
|
+
}
|
|
140
|
+
const off = ops.filter((op) => op.op === 'update' && (op.changes || []).some(offOnServer));
|
|
141
|
+
if (off.length) {
|
|
142
|
+
const many = off.length > 1;
|
|
143
|
+
output.warn(`${off.length} polic${many ? 'ies are' : 'y is'} off on the server but on in the pack (switched off in the console, or just enabled in the file). Apply turns ${many ? 'them' : 'it'} on; to keep ${many ? 'one' : 'it'} off, set enabled: false in the pack file.`);
|
|
144
|
+
}
|
|
145
|
+
const s = summarize(ops);
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (s.create) parts.push(`${s.create} to create`);
|
|
148
|
+
if (s.update) parts.push(`${s.update} to update`);
|
|
149
|
+
if (s.replace) parts.push(`${s.replace} to replace`);
|
|
150
|
+
if (s.delete) parts.push(`${s.delete} to delete`);
|
|
151
|
+
if (s.orphan) parts.push(`${s.orphan} kept (not in file)`);
|
|
152
|
+
if (s.conflict) parts.push(`${s.conflict} conflict${s.conflict > 1 ? 's' : ''}`);
|
|
153
|
+
parts.push(`${s.unchanged} unchanged`);
|
|
154
|
+
console.log(` ${parts.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function planJson({ file, pack, ops }) {
|
|
158
|
+
return {
|
|
159
|
+
pack: pack.name,
|
|
160
|
+
version: pack.version,
|
|
161
|
+
file,
|
|
162
|
+
summary: summarize(ops),
|
|
163
|
+
ops: ops.map((op) => ({
|
|
164
|
+
op: op.op,
|
|
165
|
+
kind: op.kind,
|
|
166
|
+
policy: op.serverName,
|
|
167
|
+
id: op.id,
|
|
168
|
+
changes: op.changes || [],
|
|
169
|
+
reason: op.reason || null,
|
|
170
|
+
})),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function planPack(pack, { prune, names }) {
|
|
175
|
+
const needsMcp = pack.policies.some((p) => p.kind === 'tool' && p.type === 'MCP_TOOL' && p.mcp && p.mcp.actionType);
|
|
176
|
+
const ref = await remote.loadReference({ mcp: needsMcp });
|
|
177
|
+
if (needsMcp && ref.mcpServersError) {
|
|
178
|
+
// Without the catalogue an actionType cannot be compared; refusing beats a plan that calls stale tools "unchanged".
|
|
179
|
+
throw new PackError(`could not load the MCP server catalogue (${ref.mcpServersError}); this pack uses mcp.actionType, which needs it. Retry, or list mcp.tools explicitly`);
|
|
180
|
+
}
|
|
181
|
+
const desired = toDesired(pack, ref);
|
|
182
|
+
const live = await remote.fetchManaged(pack.name);
|
|
183
|
+
live.names = names || await remote.fetchAllNames();
|
|
184
|
+
const ops = buildPlan(desired, live, { prune });
|
|
185
|
+
return { ops, live, ref };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Shared body of apply / sync / diff.
|
|
190
|
+
* Returns { drift, failed } so callers can set the exit code.
|
|
191
|
+
*/
|
|
192
|
+
async function runApply(target, { dryRun = false, prune = false, yes = false, json = false, label = 'apply' }) {
|
|
193
|
+
requireLogin();
|
|
194
|
+
const packs = loadPackFiles(target);
|
|
195
|
+
// Names are unique per org, so two packs in one run cannot both claim one.
|
|
196
|
+
const nameOwner = new Map();
|
|
197
|
+
for (const { file, pack } of packs) {
|
|
198
|
+
for (const policy of pack.policies) {
|
|
199
|
+
const owner = nameOwner.get(policy.serverName);
|
|
200
|
+
if (owner && owner.pack !== pack.name) {
|
|
201
|
+
throw new PackError(`the name "${policy.serverName}" is used by both ${owner.pack} (${owner.file}) and ${pack.name} (${file}); names must be unique in the org`);
|
|
202
|
+
}
|
|
203
|
+
nameOwner.set(policy.serverName, { pack: pack.name, file });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Every live name in the org, loaded once per run and shared by every pack's plan.
|
|
207
|
+
const names = await remote.fetchAllNames();
|
|
208
|
+
const planned = [];
|
|
209
|
+
for (const { file, pack } of packs) {
|
|
210
|
+
const { ops, live } = await planPack(pack, { prune, names });
|
|
211
|
+
planned.push({ file, pack, ops, live });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const totals = planned.reduce((acc, p) => {
|
|
215
|
+
const s = summarize(p.ops);
|
|
216
|
+
for (const k of Object.keys(s)) acc[k] = (acc[k] || 0) + s[k];
|
|
217
|
+
return acc;
|
|
218
|
+
}, {});
|
|
219
|
+
|
|
220
|
+
if (!json) planned.forEach(printPlan);
|
|
221
|
+
|
|
222
|
+
if (totals.conflict) {
|
|
223
|
+
if (json) output.json({ mode: label, dryRun, packs: planned.map(planJson) });
|
|
224
|
+
throw new PackError(`${totals.conflict} conflict${totals.conflict > 1 ? 's' : ''} found; nothing was changed`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (dryRun) {
|
|
228
|
+
if (json) output.json({ mode: label, dryRun: true, packs: planned.map(planJson) });
|
|
229
|
+
else if (totals.drift) output.info(`Dry run: ${totals.drift} change${totals.drift > 1 ? 's' : ''} pending. Run without --dry-run to apply.`);
|
|
230
|
+
else output.success('Already in sync.');
|
|
231
|
+
return { drift: totals.drift, failed: 0 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!totals.drift) {
|
|
235
|
+
if (json) output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results: [] });
|
|
236
|
+
else output.success('Already in sync.');
|
|
237
|
+
return { drift: 0, failed: 0 };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const destructive = totals.delete + totals.replace;
|
|
241
|
+
if (destructive && !yes) {
|
|
242
|
+
const ok = await confirm(`This will delete ${destructive} polic${destructive > 1 ? 'ies' : 'y'} (${totals.delete} delete, ${totals.replace} replace). Continue?`);
|
|
243
|
+
if (!ok) {
|
|
244
|
+
output.warn('Aborted. Nothing was changed.');
|
|
245
|
+
return { drift: totals.drift, failed: 0, aborted: true };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const results = [];
|
|
250
|
+
for (const p of planned) {
|
|
251
|
+
const todo = p.ops.filter((op) => ['create', 'update', 'replace', 'delete'].includes(op.op));
|
|
252
|
+
const packResults = await remote.writeOps(todo, {
|
|
253
|
+
onResult: (r) => {
|
|
254
|
+
if (json) return;
|
|
255
|
+
const line = `${ACTION_LABEL[r.op.op]} ${r.op.serverName}`;
|
|
256
|
+
if (r.status === 'ok') output.success(line);
|
|
257
|
+
else output.error(`${line}: ${r.error}`);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
results.push(...packResults.map((r) => ({ pack: p.pack.name, policy: r.op.serverName, op: r.op.op, status: r.status, error: r.error })));
|
|
261
|
+
}
|
|
262
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
263
|
+
if (json) {
|
|
264
|
+
output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results });
|
|
265
|
+
} else {
|
|
266
|
+
console.log('');
|
|
267
|
+
if (failed) output.warn(`${results.length - failed} succeeded, ${failed} failed. Re-run to retry; every operation is idempotent by name.`);
|
|
268
|
+
else output.success(`${results.length} change${results.length > 1 ? 's' : ''} applied.`);
|
|
269
|
+
}
|
|
270
|
+
return { drift: totals.drift, failed };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function writeOrPrint(text, outPath) {
|
|
274
|
+
if (!outPath) {
|
|
275
|
+
process.stdout.write(text);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
279
|
+
fs.writeFileSync(outPath, text);
|
|
280
|
+
output.success(`Wrote ${outPath}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============================================================================
|
|
284
|
+
// register(program)
|
|
285
|
+
// ============================================================================
|
|
286
|
+
|
|
287
|
+
function register(program) {
|
|
288
|
+
const pack = program
|
|
289
|
+
.command('pack')
|
|
290
|
+
.description('Policy packs as code: keep policies in YAML files and sync them to your org.')
|
|
291
|
+
.addHelpText('after', `
|
|
292
|
+
A pack file is one versioned bundle of policies (kind: tool, security, model or
|
|
293
|
+
cost). Every policy in a pack is marked on the server as managed by code, with
|
|
294
|
+
the pack name and the policy id as its identity; its name is free text. The
|
|
295
|
+
pack commands only ever touch policies carrying that mark. Policies made in the
|
|
296
|
+
console are never read for diffing and never written.
|
|
297
|
+
|
|
298
|
+
validate Check pack files without contacting the server
|
|
299
|
+
diff Show what apply would change; exit 1 when there is drift (CI)
|
|
300
|
+
apply Create and update policies from a file or folder (--prune deletes)
|
|
301
|
+
sync Folder is the source of truth: apply every pack with --prune
|
|
302
|
+
export Write the live policies of a pack back to YAML
|
|
303
|
+
adopt Mark console-made policies as managed by a pack (names stay)
|
|
304
|
+
delete Remove every policy a pack manages
|
|
305
|
+
|
|
306
|
+
Examples:
|
|
307
|
+
$ unbound pack validate -f policies/
|
|
308
|
+
$ unbound pack diff -f policies/k8s-baseline.yaml
|
|
309
|
+
$ unbound pack apply -f policies/k8s-baseline.yaml --dry-run
|
|
310
|
+
$ unbound pack apply -f policies/ --prune
|
|
311
|
+
$ unbound pack sync policies/ --yes
|
|
312
|
+
$ unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
|
|
313
|
+
$ unbound pack adopt k8s-baseline --from "Block kubectl secrets" --from "Audit git push"
|
|
314
|
+
|
|
315
|
+
Learn more: ${DOCS_PACKS}
|
|
316
|
+
`);
|
|
317
|
+
|
|
318
|
+
pack
|
|
319
|
+
.command('validate')
|
|
320
|
+
.description('Validate pack files locally. No server contact.')
|
|
321
|
+
.requiredOption('-f, --file <path>', 'Pack file, or a folder of pack files')
|
|
322
|
+
.action((opts) => {
|
|
323
|
+
try {
|
|
324
|
+
const packs = loadPackFiles(opts.file);
|
|
325
|
+
for (const { file, pack: p } of packs) {
|
|
326
|
+
output.success(`${file}: pack "${p.name}" v${p.version}, ${p.policies.length} polic${p.policies.length > 1 ? 'ies' : 'y'}`);
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
fail(err);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
pack
|
|
334
|
+
.command('diff')
|
|
335
|
+
.description('Compare pack files with the org. Exit 0 when in sync, 1 on drift, 2 on any error (validation, login, network).')
|
|
336
|
+
.requiredOption('-f, --file <path>', 'Pack file, or a folder of pack files')
|
|
337
|
+
.option('--prune', 'Count managed policies missing from the file as drift (they would be deleted)')
|
|
338
|
+
.option('--json', 'Output the plan as JSON')
|
|
339
|
+
.action(async (opts) => {
|
|
340
|
+
try {
|
|
341
|
+
const { drift } = await runApply(opts.file, { dryRun: true, prune: !!opts.prune, json: !!opts.json, label: 'diff' });
|
|
342
|
+
process.exitCode = drift ? 1 : 0;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// 1 is reserved for drift so CI can tell "the org differs" from "the run broke".
|
|
345
|
+
output.error(err.message);
|
|
346
|
+
process.exitCode = 2;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
pack
|
|
351
|
+
.command('apply')
|
|
352
|
+
.description('Create and update policies so they match the pack file(s). Deletes only with --prune.')
|
|
353
|
+
.requiredOption('-f, --file <path>', 'Pack file, or a folder of pack files')
|
|
354
|
+
.option('--dry-run', 'Print the plan and change nothing')
|
|
355
|
+
.option('--prune', 'Delete managed policies that are not in the file')
|
|
356
|
+
.option('--yes', 'Skip the confirmation prompt for deletes')
|
|
357
|
+
.option('--json', 'Output the plan and results as JSON')
|
|
358
|
+
.addHelpText('after', `
|
|
359
|
+
Examples:
|
|
360
|
+
$ unbound pack apply -f k8s-baseline.yaml --dry-run
|
|
361
|
+
$ unbound pack apply -f k8s-baseline.yaml
|
|
362
|
+
$ unbound pack apply -f policies/ --prune --yes
|
|
363
|
+
|
|
364
|
+
Updates send only the fields that differ. Re-running the same file is safe.
|
|
365
|
+
Learn more: ${DOCS_PACKS}
|
|
366
|
+
`)
|
|
367
|
+
.action(async (opts) => {
|
|
368
|
+
try {
|
|
369
|
+
const { failed } = await runApply(opts.file, { dryRun: !!opts.dryRun, prune: !!opts.prune, yes: !!opts.yes, json: !!opts.json, label: 'apply' });
|
|
370
|
+
if (failed) process.exitCode = 1;
|
|
371
|
+
} catch (err) {
|
|
372
|
+
fail(err);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
pack
|
|
377
|
+
.command('sync <path>')
|
|
378
|
+
.description('Make the org match a folder (or file) of pack files: apply every pack with --prune.')
|
|
379
|
+
.option('--dry-run', 'Print the plan and change nothing')
|
|
380
|
+
.option('--yes', 'Skip the confirmation prompt for deletes')
|
|
381
|
+
.option('--json', 'Output the plan and results as JSON')
|
|
382
|
+
.addHelpText('after', `
|
|
383
|
+
Examples:
|
|
384
|
+
$ unbound pack sync policies/ --dry-run
|
|
385
|
+
$ unbound pack sync policies/ --yes
|
|
386
|
+
|
|
387
|
+
A pack whose file is removed from the folder keeps its policies: the server
|
|
388
|
+
has no record of which packs were applied before. Use \`unbound pack delete <pack>\`
|
|
389
|
+
to remove a whole pack.
|
|
390
|
+
`)
|
|
391
|
+
.action(async (target, opts) => {
|
|
392
|
+
try {
|
|
393
|
+
const { failed } = await runApply(target, { dryRun: !!opts.dryRun, prune: true, yes: !!opts.yes, json: !!opts.json, label: 'sync' });
|
|
394
|
+
if (failed) process.exitCode = 1;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
fail(err);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
pack
|
|
401
|
+
.command('export [pack]')
|
|
402
|
+
.description('Write the live policies of a pack as a pack file.')
|
|
403
|
+
.option('-o, --out <path>', 'File to write (default: stdout). With --all: a folder, one file per pack')
|
|
404
|
+
.option('--all', 'Export every pack found in the org')
|
|
405
|
+
.option('--pack-version <version>', 'metadata.version to write (default 1.0.0). Not --version, which prints the CLI version', '1.0.0')
|
|
406
|
+
.option('--json', 'Write JSON instead of YAML')
|
|
407
|
+
.addHelpText('after', `
|
|
408
|
+
Examples:
|
|
409
|
+
$ unbound pack export k8s-baseline
|
|
410
|
+
$ unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
|
|
411
|
+
$ unbound pack export --all -o policies/
|
|
412
|
+
`)
|
|
413
|
+
.action(async (packName, opts) => {
|
|
414
|
+
try {
|
|
415
|
+
requireLogin();
|
|
416
|
+
if (!packName && !opts.all) throw new PackError('pass a pack name, or --all');
|
|
417
|
+
if (packName && !SLUG_RE.test(packName)) throw new PackError('pack name must match [a-z0-9][a-z0-9-]{0,63}');
|
|
418
|
+
const ref = await remote.loadReference();
|
|
419
|
+
const serialize = (doc) => (opts.json ? `${JSON.stringify(doc, null, 2)}\n` : stringifyPack(doc));
|
|
420
|
+
const ext = opts.json ? '.json' : '.yaml';
|
|
421
|
+
|
|
422
|
+
if (opts.all) {
|
|
423
|
+
if (!opts.out) throw new PackError('--all needs -o <folder>');
|
|
424
|
+
const { packs, unmanaged } = await remote.fetchAllGrouped();
|
|
425
|
+
if (!packs.size) {
|
|
426
|
+
output.info('No pack-managed policies found in this org.');
|
|
427
|
+
}
|
|
428
|
+
fs.mkdirSync(opts.out, { recursive: true });
|
|
429
|
+
for (const [name, live] of [...packs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
430
|
+
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
|
|
431
|
+
if (!live.tool.length && !live.generic.length) {
|
|
432
|
+
output.warn(`pack "${name}": only unsupported policies, no file written.`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const doc = liveToPack(name, live, ref, { version: opts.packVersion });
|
|
436
|
+
writeOrPrint(serialize(doc), path.join(opts.out, `${name}${ext}`));
|
|
437
|
+
}
|
|
438
|
+
if (unmanaged.length) {
|
|
439
|
+
output.info(`${unmanaged.length} polic${unmanaged.length > 1 ? 'ies are' : 'y is'} not in any pack. Use \`unbound pack adopt\` to bring them in.`);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const live = await remote.fetchManaged(packName);
|
|
445
|
+
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
|
|
446
|
+
if (!live.tool.length && !live.generic.length) {
|
|
447
|
+
throw new PackError(live.unsupported.length
|
|
448
|
+
? `pack "${packName}" only has policies of types this CLI version does not manage; nothing to export`
|
|
449
|
+
: `pack "${packName}" manages no policies in this org`);
|
|
450
|
+
}
|
|
451
|
+
const doc = liveToPack(packName, live, ref, { version: opts.packVersion });
|
|
452
|
+
writeOrPrint(serialize(doc), opts.out);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
fail(err);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
pack
|
|
459
|
+
.command('adopt <pack>')
|
|
460
|
+
.description('Bring console-made policies into a pack: marks them as managed by code with this pack and an id. Names stay as they are. Prints the resulting pack file.')
|
|
461
|
+
.requiredOption('--from <policy name>', 'Exact name of an existing policy. Repeatable.', collect)
|
|
462
|
+
.option('--id <slug>', 'Id to use (only with a single --from). Default: a slug of the policy name')
|
|
463
|
+
.option('--yes', 'Skip the confirmation prompt')
|
|
464
|
+
.option('-o, --out <path>', 'Write the resulting pack file here (default: stdout; progress goes to stderr)')
|
|
465
|
+
.option('--pack-version <version>', 'metadata.version for the printed pack file (default 1.0.0)', '1.0.0')
|
|
466
|
+
.addHelpText('after', `
|
|
467
|
+
Examples:
|
|
468
|
+
$ unbound pack adopt k8s-baseline --from "Block kubectl secrets"
|
|
469
|
+
$ unbound pack adopt k8s-baseline --from "Block kubectl secrets" --id block-secret-reads
|
|
470
|
+
$ unbound pack adopt k8s-baseline --from "Audit git push" --from "Block rm -rf" --yes -o policies/k8s-baseline.yaml
|
|
471
|
+
|
|
472
|
+
Adoption changes no policy content: it marks the policy as managed by code so the
|
|
473
|
+
console only switches it on or off from then on. Commit the written file, then
|
|
474
|
+
manage the pack with apply.
|
|
475
|
+
`)
|
|
476
|
+
.action(async (packName, opts) => {
|
|
477
|
+
try {
|
|
478
|
+
requireLogin();
|
|
479
|
+
if (!SLUG_RE.test(packName)) throw new PackError('pack name must match [a-z0-9][a-z0-9-]{0,63}');
|
|
480
|
+
if (opts.id && opts.from.length !== 1) throw new PackError('--id works with exactly one --from');
|
|
481
|
+
if (opts.id && !SLUG_RE.test(opts.id)) throw new PackError('--id must match [a-z0-9][a-z0-9-]{0,63}');
|
|
482
|
+
|
|
483
|
+
const adoptions = [];
|
|
484
|
+
for (const name of opts.from) {
|
|
485
|
+
const hits = await remote.findByExactName(name);
|
|
486
|
+
if (!hits.length) throw new PackError(`no policy named "${name}"`);
|
|
487
|
+
if (hits.length > 1) throw new PackError(`"${name}" matches ${hits.length} policies (a tool policy and a generic policy share that name); rename one in the console first`);
|
|
488
|
+
if (!hits[0].supported) {
|
|
489
|
+
const type = hits[0].row.policy_type || hits[0].row.type;
|
|
490
|
+
throw new PackError(`"${name}" is a ${type} policy, which this CLI version does not manage; it cannot join a pack`);
|
|
491
|
+
}
|
|
492
|
+
if (hits[0].row.origin === 'CODE' && hits[0].row.code_pack) {
|
|
493
|
+
throw new PackError(`"${name}" is already managed by pack ${hits[0].row.code_pack} (id ${hits[0].row.code_slug})`);
|
|
494
|
+
}
|
|
495
|
+
const id = opts.id || slugify(name);
|
|
496
|
+
if (!id) throw new PackError(`cannot derive an id from "${name}"; pass --id`);
|
|
497
|
+
adoptions.push({ ...hits[0], id });
|
|
498
|
+
}
|
|
499
|
+
const dupes = adoptions.map((r) => r.id).filter((id, i, all) => all.indexOf(id) !== i);
|
|
500
|
+
if (dupes.length) throw new PackError(`two policies would get the same id: ${[...new Set(dupes)].join(', ')}`);
|
|
501
|
+
|
|
502
|
+
const existing = await remote.fetchManaged(packName);
|
|
503
|
+
for (const r of adoptions) {
|
|
504
|
+
const taken = [...existing.tool, ...existing.generic, ...existing.unsupported.map((u) => u.row)].find((row) => row.code_slug === r.id);
|
|
505
|
+
if (taken) throw new PackError(`id "${r.id}" is already used in pack ${packName} by "${taken.name}"; pass --id`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const r of adoptions) output.info(`${r.row.name} -> ${packName}/${r.id}`);
|
|
509
|
+
if (!opts.yes) {
|
|
510
|
+
const ok = await confirm(`Mark ${adoptions.length} polic${adoptions.length > 1 ? 'ies' : 'y'} as managed by pack "${packName}"?`);
|
|
511
|
+
if (!ok) { output.warn('Aborted.'); return; }
|
|
512
|
+
}
|
|
513
|
+
for (const r of adoptions) {
|
|
514
|
+
await remote.updateRow(r.table, r.row.id, { code_pack: packName, code_slug: r.id });
|
|
515
|
+
// stderr on purpose: stdout carries only the pack file so it can be redirected.
|
|
516
|
+
output.info(`Adopted "${r.row.name}" as ${packName}/${r.id}`);
|
|
517
|
+
}
|
|
518
|
+
const ref = await remote.loadReference();
|
|
519
|
+
const live = await remote.fetchManaged(packName);
|
|
520
|
+
writeOrPrint(stringifyPack(liveToPack(packName, live, ref, { version: opts.packVersion })), opts.out);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
fail(err);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
pack
|
|
527
|
+
.command('delete <pack>')
|
|
528
|
+
.description('Delete every policy a pack manages.')
|
|
529
|
+
.option('--yes', 'Skip the confirmation prompt')
|
|
530
|
+
.action(async (packName, opts) => {
|
|
531
|
+
try {
|
|
532
|
+
requireLogin();
|
|
533
|
+
if (!SLUG_RE.test(packName)) throw new PackError('pack name must match [a-z0-9][a-z0-9-]{0,63}');
|
|
534
|
+
const live = await remote.fetchManaged(packName);
|
|
535
|
+
const rows = [
|
|
536
|
+
...live.tool.map((row) => ({ table: 'tool', row })),
|
|
537
|
+
...live.generic.map((row) => ({ table: 'generic', row })),
|
|
538
|
+
];
|
|
539
|
+
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, left untouched; delete it in the console.`);
|
|
540
|
+
if (!rows.length) {
|
|
541
|
+
output.info(`Pack "${packName}" manages no policies of a managed type in this org.`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
for (const { row } of rows) console.log(` ${row.name}`);
|
|
545
|
+
if (!opts.yes) {
|
|
546
|
+
const ok = await confirm(`Delete these ${rows.length} polic${rows.length > 1 ? 'ies' : 'y'}?`);
|
|
547
|
+
if (!ok) { output.warn('Aborted.'); return; }
|
|
548
|
+
}
|
|
549
|
+
let failed = 0;
|
|
550
|
+
for (const { table, row } of rows) {
|
|
551
|
+
try {
|
|
552
|
+
await remote.deleteRow(table, row.id);
|
|
553
|
+
output.success(`Deleted ${row.name}`);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
failed += 1;
|
|
556
|
+
output.error(`${row.name}: ${err.message}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (failed) process.exitCode = 1;
|
|
560
|
+
} catch (err) {
|
|
561
|
+
fail(err);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
module.exports = { register, _internals: { loadPackFiles, changeText, runApply, confirm } };
|
package/src/commands/status.js
CHANGED
|
@@ -4,9 +4,12 @@ const output = require('../output');
|
|
|
4
4
|
const { getDeviceSerial } = require('../device-serial');
|
|
5
5
|
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
6
6
|
const claudePaths = require('../claudePaths');
|
|
7
|
+
const { describeToken } = require('../login-scope');
|
|
7
8
|
|
|
8
9
|
function roleFromPrivileges(p) {
|
|
9
10
|
if (!p) return null;
|
|
11
|
+
// A scoped token reports its own cap, not the owner's role flags.
|
|
12
|
+
if (p.cli_token && p.cli_token.permissions) return 'Scoped token';
|
|
10
13
|
if (p.is_admin) return 'Admin';
|
|
11
14
|
if (p.is_analytics_viewer) return 'Analytics Viewer';
|
|
12
15
|
if (p.is_manager) return 'Manager';
|
|
@@ -25,7 +28,8 @@ Output fields:
|
|
|
25
28
|
Logged in - Whether credentials are stored (Yes/No)
|
|
26
29
|
Email - The authenticated user's email (if logged in)
|
|
27
30
|
Organization - The organization name (if logged in)
|
|
28
|
-
Role - Admin / Analytics Viewer / Manager / Member (if logged in)
|
|
31
|
+
Role - Admin / Analytics Viewer / Manager / Member, or "Scoped token" (if logged in)
|
|
32
|
+
Token - Login token name, scope and expiry (if signed in with a CLI token)
|
|
29
33
|
Backend URL - REST API host (configurable for tenant deployments)
|
|
30
34
|
Frontend URL - Browser login host
|
|
31
35
|
Gateway URL - AI gateway host (used by tool setup)
|
|
@@ -51,6 +55,7 @@ Examples:
|
|
|
51
55
|
|
|
52
56
|
let connectivity = 'Not checked (not logged in)';
|
|
53
57
|
let role = null;
|
|
58
|
+
let token = null;
|
|
54
59
|
if (loggedIn) {
|
|
55
60
|
const spin = output.spinner('Checking API connectivity...');
|
|
56
61
|
try {
|
|
@@ -60,6 +65,7 @@ Examples:
|
|
|
60
65
|
});
|
|
61
66
|
config.backfillUserInfo(privileges);
|
|
62
67
|
role = roleFromPrivileges(privileges);
|
|
68
|
+
token = describeToken(privileges);
|
|
63
69
|
spin.stop();
|
|
64
70
|
connectivity = 'Connected';
|
|
65
71
|
} catch (err) {
|
|
@@ -71,6 +77,7 @@ Examples:
|
|
|
71
77
|
pairs.push(['Email', cfg.email || '-']);
|
|
72
78
|
pairs.push(['Organization', cfg.org_name || '-']);
|
|
73
79
|
pairs.push(['Role', role || '-']);
|
|
80
|
+
if (token) pairs.push(['Token', token]);
|
|
74
81
|
}
|
|
75
82
|
pairs.push(['Backend URL', config.getBaseUrl()]);
|
|
76
83
|
pairs.push(['Frontend URL', config.getFrontendUrl()]);
|
|
@@ -100,6 +107,7 @@ Examples:
|
|
|
100
107
|
email: loggedIn ? (cfg.email || null) : null,
|
|
101
108
|
organization: loggedIn ? (cfg.org_name || null) : null,
|
|
102
109
|
role: role,
|
|
110
|
+
token: token,
|
|
103
111
|
backend_url: config.getBaseUrl(),
|
|
104
112
|
frontend_url: config.getFrontendUrl(),
|
|
105
113
|
gateway_url: config.getGatewayUrl(),
|
package/src/index.js
CHANGED
|
@@ -38,6 +38,7 @@ AUTHENTICATION
|
|
|
38
38
|
$ unbound login Sign in via browser
|
|
39
39
|
$ unbound login --api-key <key> Sign in with an API key (for CI/CD)
|
|
40
40
|
$ unbound login --domain custom.co Sign in via custom domain
|
|
41
|
+
$ unbound login --role analytics-viewer Browser sign-in with a view-only token
|
|
41
42
|
$ unbound logout Remove stored credentials
|
|
42
43
|
$ unbound status Show CLI status, role, and connected tools
|
|
43
44
|
$ unbound doctor Diagnose per-tool health and API key
|
|
@@ -138,6 +139,15 @@ POLICY MANAGEMENT
|
|
|
138
139
|
$ unbound policy tool families Discover terminal command families
|
|
139
140
|
$ unbound policy tool mcp-servers Discover MCP servers and their tools
|
|
140
141
|
|
|
142
|
+
Policy packs as code — keep policies in YAML and sync them from a folder:
|
|
143
|
+
$ unbound pack validate -f policies/ Check pack files locally (no server contact)
|
|
144
|
+
$ unbound pack diff -f policies/ Show drift; exit 1 when the org differs (CI)
|
|
145
|
+
$ unbound pack apply -f policies/ --dry-run
|
|
146
|
+
$ unbound pack apply -f policies/ --prune
|
|
147
|
+
$ unbound pack sync policies/ Folder is the source of truth (apply + prune)
|
|
148
|
+
$ unbound pack export <pack> -o file.yaml
|
|
149
|
+
$ unbound pack adopt <pack> --from "<policy name>"
|
|
150
|
+
|
|
141
151
|
USER & GROUP MANAGEMENT
|
|
142
152
|
$ unbound users list List organization members
|
|
143
153
|
$ unbound users effective-policies <id> View effective policies for a user
|
|
@@ -211,6 +221,7 @@ require('./commands/logout').register(program);
|
|
|
211
221
|
require('./commands/status').register(program);
|
|
212
222
|
require('./commands/doctor').register(program);
|
|
213
223
|
require('./commands/policy').register(program);
|
|
224
|
+
require('./commands/pack').register(program);
|
|
214
225
|
require('./commands/users').register(program);
|
|
215
226
|
require('./commands/user-groups').register(program);
|
|
216
227
|
require('./commands/tools').register(program);
|