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
package/README.md
CHANGED
|
@@ -212,6 +212,66 @@ unbound policy tool delete <id>
|
|
|
212
212
|
|
|
213
213
|
Before creating any policy, run `unbound policy form-data` to see available user group names, model names, guardrail names, and existing command policies. The CLI accepts names (e.g. `engg`, `claude-3-opus`, `PII`) and resolves them to backend IDs automatically. You can also pass numeric IDs directly.
|
|
214
214
|
|
|
215
|
+
### Policy packs as code (Admin only)
|
|
216
|
+
|
|
217
|
+
Keep policies in versioned YAML files and sync them to your org with `unbound pack`. One file is one **pack**: a named, versioned bundle of one or more policies of kind `tool`, `security`, `model` or `cost`. Every policy in a pack is marked on the server as managed by code, with the pack name and the policy `id` as its identity; its `name` is free text (it defaults to the id). The pack commands match, update and prune only by that identity, so policies made in the console are never read for diffing and never written, whatever they are called.
|
|
218
|
+
|
|
219
|
+
The console shows a **Managed by Code** badge on those policies and only lets people switch them on or off; every other change goes through the pack file. A policy switched off in the console shows up in `pack diff` as `enabled: false -> true (off on the server, on in the pack)`, and `pack apply` turns it back on (set `enabled: false` in the file to keep it off). `unbound pack adopt` brings a console policy into a pack without renaming it. Needs a backend that stores the code identity (ai-gateway-data 0448 or later).
|
|
220
|
+
|
|
221
|
+
Docs: https://docs.getunbound.ai/policies/policy-as-code
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
# policies/k8s-baseline.yaml
|
|
225
|
+
apiVersion: unbound.security/v1
|
|
226
|
+
kind: PolicyPack
|
|
227
|
+
metadata:
|
|
228
|
+
name: k8s-baseline # stored on every policy below as its pack identity
|
|
229
|
+
version: 1.0.0
|
|
230
|
+
defaults:
|
|
231
|
+
scope:
|
|
232
|
+
userGroups: [Engineering]
|
|
233
|
+
policies:
|
|
234
|
+
- id: block-secret-reads
|
|
235
|
+
kind: tool
|
|
236
|
+
type: TERMINAL_COMMAND
|
|
237
|
+
action: BLOCK
|
|
238
|
+
commandFamily: custom
|
|
239
|
+
customMessage: Reading cluster secrets is blocked by the k8s-baseline pack.
|
|
240
|
+
config:
|
|
241
|
+
command: "regex:kubectl\\s+(get|describe)\\s+secrets?"
|
|
242
|
+
- id: github-writes-need-approval
|
|
243
|
+
kind: tool
|
|
244
|
+
type: MCP_TOOL
|
|
245
|
+
action: REQUIRE_SLACK_APPROVAL
|
|
246
|
+
mcp:
|
|
247
|
+
server: github
|
|
248
|
+
actionType: write
|
|
249
|
+
- id: pii-guard
|
|
250
|
+
kind: security
|
|
251
|
+
guardrails:
|
|
252
|
+
- name: PII
|
|
253
|
+
action: BLOCK
|
|
254
|
+
- id: monthly-cap
|
|
255
|
+
kind: cost
|
|
256
|
+
config:
|
|
257
|
+
monthly_budget: 5000
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
unbound pack validate -f policies/ # schema check, no server contact
|
|
262
|
+
unbound pack diff -f policies/k8s-baseline.yaml # what apply would change; exit 1 on drift
|
|
263
|
+
unbound pack apply -f policies/k8s-baseline.yaml --dry-run
|
|
264
|
+
unbound pack apply -f policies/k8s-baseline.yaml
|
|
265
|
+
unbound pack apply -f policies/ --prune # also delete managed policies missing from the files
|
|
266
|
+
unbound pack sync policies/ --yes # folder is the source of truth (apply + prune)
|
|
267
|
+
unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
|
|
268
|
+
unbound pack export --all -o policies/ # one file per pack found in the org
|
|
269
|
+
unbound pack adopt k8s-baseline --from "Block kubectl secrets" -o policies/k8s-baseline.yaml # mark a console policy as managed by the pack; its name stays
|
|
270
|
+
unbound pack delete k8s-baseline --yes # remove every policy the pack manages
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
User groups, guardrails, models, labels and MCP servers are written by name and resolved at apply time; a name that does not resolve fails before anything is written. `config` is passed to the server as-is. Updates send only the fields that differ, and re-running the same file is safe. `diff` and `--dry-run` never write. A removed pack file does not delete its policies (use `pack delete`), because the server keeps no record of applied packs yet.
|
|
274
|
+
|
|
215
275
|
### Users
|
|
216
276
|
|
|
217
277
|
| Command | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unbound-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "CLI tool for Unbound - AI Gateway management",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"@iarna/toml": "^2.2.5",
|
|
24
24
|
"@sentry/node": "^10.59.0",
|
|
25
25
|
"commander": "^12.1.0",
|
|
26
|
-
"open": "^10.1.0"
|
|
26
|
+
"open": "^10.1.0",
|
|
27
|
+
"yaml": "^2.9.1"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=18.0.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/index.js
CHANGED
|
@@ -138,6 +138,15 @@ POLICY MANAGEMENT
|
|
|
138
138
|
$ unbound policy tool families Discover terminal command families
|
|
139
139
|
$ unbound policy tool mcp-servers Discover MCP servers and their tools
|
|
140
140
|
|
|
141
|
+
Policy packs as code — keep policies in YAML and sync them from a folder:
|
|
142
|
+
$ unbound pack validate -f policies/ Check pack files locally (no server contact)
|
|
143
|
+
$ unbound pack diff -f policies/ Show drift; exit 1 when the org differs (CI)
|
|
144
|
+
$ unbound pack apply -f policies/ --dry-run
|
|
145
|
+
$ unbound pack apply -f policies/ --prune
|
|
146
|
+
$ unbound pack sync policies/ Folder is the source of truth (apply + prune)
|
|
147
|
+
$ unbound pack export <pack> -o file.yaml
|
|
148
|
+
$ unbound pack adopt <pack> --from "<policy name>"
|
|
149
|
+
|
|
141
150
|
USER & GROUP MANAGEMENT
|
|
142
151
|
$ unbound users list List organization members
|
|
143
152
|
$ unbound users effective-policies <id> View effective policies for a user
|
|
@@ -211,6 +220,7 @@ require('./commands/logout').register(program);
|
|
|
211
220
|
require('./commands/status').register(program);
|
|
212
221
|
require('./commands/doctor').register(program);
|
|
213
222
|
require('./commands/policy').register(program);
|
|
223
|
+
require('./commands/pack').register(program);
|
|
214
224
|
require('./commands/users').register(program);
|
|
215
225
|
require('./commands/user-groups').register(program);
|
|
216
226
|
require('./commands/tools').register(program);
|