unbound-cli 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Skeleton eval runner for WEB-4887 Phase 1. Loads policy-prompts.json and
3
+ // prints a per-category summary. Manual: run this against a staging admin
4
+ // API key — CI does NOT execute it.
5
+ //
6
+ // To wire up an actual run:
7
+ // 1. For each prompt, spawn `unbound policy tool create-terminal --prompt <p>`
8
+ // (or, in pick-rate mode, ask Claude Code to satisfy the natural-language
9
+ // ask and observe which command it invokes).
10
+ // 2. Capture stdout/stderr and the exit code.
11
+ // 3. Compare against `expected_outcome` to compute pick-rate and success-rate.
12
+ //
13
+ // The skeleton intentionally stops at the "load + summary" boundary — Dinesh
14
+ // fills in the actual invocation strategy after Phase 1 lands.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const PROMPTS_PATH = path.join(__dirname, 'policy-prompts.json');
20
+
21
+ function expandOversize(prompt) {
22
+ // The fixture stores oversize cases as a marker so the JSON stays readable.
23
+ // Expand to a real 2200/2500/3000-char string at run time.
24
+ const m = /^OVERSIZE_FILL_(\d+)$/.exec(prompt);
25
+ if (!m) return prompt;
26
+ return 'x'.repeat(parseInt(m[1], 10));
27
+ }
28
+
29
+ function loadPrompts() {
30
+ const raw = fs.readFileSync(PROMPTS_PATH, 'utf8');
31
+ const prompts = JSON.parse(raw);
32
+ return prompts.map((p) => ({ ...p, prompt: expandOversize(p.prompt) }));
33
+ }
34
+
35
+ function summarize(prompts) {
36
+ const byCategory = {};
37
+ for (const p of prompts) {
38
+ byCategory[p.category] = (byCategory[p.category] || 0) + 1;
39
+ }
40
+ console.log(`Loaded ${prompts.length} eval prompts:`);
41
+ for (const [cat, n] of Object.entries(byCategory)) {
42
+ console.log(` ${cat.padEnd(28)} ${n}`);
43
+ }
44
+ console.log('');
45
+ console.log('TODO(Dinesh): wire `unbound policy tool create-terminal --prompt`');
46
+ console.log(' invocations here and aggregate pick-rate / success-rate.');
47
+ }
48
+
49
+ function main() {
50
+ const prompts = loadPrompts();
51
+ summarize(prompts);
52
+ // TODO: spawn the CLI per prompt, record outcomes, compare to expected.
53
+ }
54
+
55
+ if (require.main === module) main();
56
+
57
+ module.exports = { loadPrompts, expandOversize };
@@ -0,0 +1,363 @@
1
+ const { test, beforeEach, after } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // Integration tests for the --no-ai steering guard (WEB-4887, layers 1+2+3).
6
+ // Invokes `unbound policy tool create-terminal` / `create-mcp` against a fresh
7
+ // Command instance with src/api.js and src/config.js stubbed. We never hit the
8
+ // network and never read/write user config.
9
+ //
10
+ // Harness mirrors test/policy-ai-assist.test.js (loadFreshModules, buildHarness,
11
+ // per-test process.exitCode reset). See PLAN risk R7.
12
+
13
+ function loadFreshModules() {
14
+ // Drop the module cache so each test gets a clean module-level
15
+ // `_privilegesCache` in policy-ai-assist and a clean stub surface.
16
+ for (const m of [
17
+ '../src/commands/policy',
18
+ '../src/lib/no-ai-guard',
19
+ '../src/lib/policy-ai-assist',
20
+ '../src/api',
21
+ '../src/config',
22
+ '../src/output',
23
+ ]) {
24
+ delete require.cache[require.resolve(m)];
25
+ }
26
+ const api = require('../src/api');
27
+ const config = require('../src/config');
28
+ const output = require('../src/output');
29
+ return { api, config, output };
30
+ }
31
+
32
+ function buildHarness({
33
+ privilegesResponse = { is_admin: true, is_manager: false, is_member: false },
34
+ assistResponse = null,
35
+ assistMcpResponse = null,
36
+ createResponse = { id: 1, name: 'ok' },
37
+ } = {}) {
38
+ const { api, config, output } = loadFreshModules();
39
+
40
+ config.isLoggedIn = () => true;
41
+ config.getApiKey = () => 'fake-key';
42
+ config.getBaseUrl = () => 'https://b.acme';
43
+
44
+ const calls = { posts: [], gets: [] };
45
+ api.get = async (path, opts) => {
46
+ calls.gets.push({ path, opts });
47
+ if (path === '/api/v1/users/privileges/') return privilegesResponse;
48
+ throw new Error(`unexpected GET ${path}`);
49
+ };
50
+ api.post = async (path, opts) => {
51
+ calls.posts.push({ path, body: opts && opts.body });
52
+ if (path === '/api/v1/command-policies/assist/') return assistResponse;
53
+ if (path === '/api/v1/command-policies/assist-mcp/') return assistMcpResponse;
54
+ if (path === '/api/v1/command-policies/') return createResponse;
55
+ throw new Error(`unexpected POST ${path}`);
56
+ };
57
+
58
+ const captured = { success: [], error: [], warn: [], info: [], stdout: [] };
59
+ output.success = (m) => captured.success.push(m);
60
+ output.error = (m) => captured.error.push(m);
61
+ output.warn = (m) => captured.warn.push(m);
62
+ output.info = (m) => captured.info.push(m);
63
+ const origLog = console.log;
64
+ console.log = (m) => captured.stdout.push(String(m || ''));
65
+ const restoreLog = () => { console.log = origLog; };
66
+
67
+ return { api, config, output, calls, captured, restoreLog };
68
+ }
69
+
70
+ async function runArgv(argv) {
71
+ const { register } = require('../src/commands/policy');
72
+ const program = new Command();
73
+ program.exitOverride();
74
+ register(program);
75
+ await program.parseAsync(['node', 'unbound', ...argv]);
76
+ }
77
+
78
+ // Save+restore the two env vars the guard reads. Always set them to a known
79
+ // state on entry (undefined unless explicitly overridden) so a stale value from
80
+ // a previous test or the ambient shell cannot leak into this test.
81
+ function withEnv(overrides, fn) {
82
+ const keys = ['CLAUDECODE', 'UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE'];
83
+ const saved = {};
84
+ for (const k of keys) {
85
+ saved[k] = process.env[k];
86
+ if (Object.prototype.hasOwnProperty.call(overrides, k)) {
87
+ if (overrides[k] === undefined) delete process.env[k];
88
+ else process.env[k] = overrides[k];
89
+ } else {
90
+ delete process.env[k];
91
+ }
92
+ }
93
+ return Promise.resolve()
94
+ .then(fn)
95
+ .finally(() => {
96
+ for (const k of keys) {
97
+ if (saved[k] === undefined) delete process.env[k];
98
+ else process.env[k] = saved[k];
99
+ }
100
+ });
101
+ }
102
+
103
+ beforeEach(() => {
104
+ process.exitCode = 0;
105
+ });
106
+
107
+ // Several tests intentionally set process.exitCode to non-zero values to
108
+ // observe the CLI's exit-code routing. Reset it at the end so node:test does
109
+ // not interpret the file itself as having failed.
110
+ after(() => {
111
+ process.exitCode = 0;
112
+ });
113
+
114
+ // T1 — Layer 1, create-terminal: no --prompt + no --no-ai → exit 2, AI steering message.
115
+ test('T1: create-terminal without --prompt and without --no-ai exits 2 with AI-assist steering message', async () => {
116
+ const h = buildHarness();
117
+ await withEnv({}, async () => {
118
+ try {
119
+ await runArgv(['policy', 'tool', 'create-terminal']);
120
+ assert.equal(process.exitCode, 2);
121
+ const errs = h.captured.error.join('\n');
122
+ assert.ok(errs.includes('AI-assisted'), `expected "AI-assisted" in error: ${errs}`);
123
+ assert.ok(errs.includes('Retry with'), `expected "Retry with" in error: ${errs}`);
124
+ assert.ok(errs.includes('--prompt'), `expected "--prompt" in error: ${errs}`);
125
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
126
+ } finally {
127
+ h.restoreLog();
128
+ }
129
+ });
130
+ });
131
+
132
+ // T2 — Layer 1, create-mcp: same shape as T1, scoped to create-mcp.
133
+ test('T2: create-mcp without --prompt and without --no-ai exits 2 with AI-assist steering message', async () => {
134
+ const h = buildHarness();
135
+ await withEnv({}, async () => {
136
+ try {
137
+ await runArgv(['policy', 'tool', 'create-mcp']);
138
+ assert.equal(process.exitCode, 2);
139
+ const errs = h.captured.error.join('\n');
140
+ assert.ok(errs.includes('AI-assisted'), `expected "AI-assisted" in error: ${errs}`);
141
+ assert.ok(errs.includes('Retry with'), `expected "Retry with" in error: ${errs}`);
142
+ assert.ok(errs.includes('--prompt'), `expected "--prompt" in error: ${errs}`);
143
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
144
+ } finally {
145
+ h.restoreLog();
146
+ }
147
+ });
148
+ });
149
+
150
+ // T3 — Layer 1 happy path, create-terminal: --no-ai + raw flags → create POST fires.
151
+ test('T3: create-terminal --no-ai with required raw flags POSTs to /command-policies/ with exit 0', async () => {
152
+ const h = buildHarness();
153
+ await withEnv({}, async () => {
154
+ try {
155
+ await runArgv([
156
+ 'policy', 'tool', 'create-terminal',
157
+ '--no-ai',
158
+ '--name', 'X',
159
+ '--command-family', 'git',
160
+ '--field', 'command=git push*',
161
+ '--action', 'AUDIT',
162
+ ]);
163
+ assert.equal(process.exitCode || 0, 0);
164
+ assert.ok(
165
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
166
+ `expected create POST, got: ${JSON.stringify(h.calls.posts)}`
167
+ );
168
+ } finally {
169
+ h.restoreLog();
170
+ }
171
+ });
172
+ });
173
+
174
+ // T4 — Layer 1 happy path, create-mcp.
175
+ test('T4: create-mcp --no-ai with required raw flags POSTs to /command-policies/ with exit 0', async () => {
176
+ const h = buildHarness();
177
+ await withEnv({}, async () => {
178
+ try {
179
+ await runArgv([
180
+ 'policy', 'tool', 'create-mcp',
181
+ '--no-ai',
182
+ '--name', 'X',
183
+ '--mcp-server', 'linear',
184
+ '--mcp-action-type', 'read',
185
+ '--action', 'AUDIT',
186
+ ]);
187
+ assert.equal(process.exitCode || 0, 0);
188
+ assert.ok(
189
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
190
+ `expected create POST, got: ${JSON.stringify(h.calls.posts)}`
191
+ );
192
+ } finally {
193
+ h.restoreLog();
194
+ }
195
+ });
196
+ });
197
+
198
+ // T5 — Mutex (layer 1): --prompt + --no-ai exits 2 with "not both" wording.
199
+ test('T5: create-terminal --prompt + --no-ai exits 2 with "not both" wording and no network call', async () => {
200
+ const h = buildHarness();
201
+ await withEnv({}, async () => {
202
+ try {
203
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'block rm -rf', '--no-ai']);
204
+ assert.equal(process.exitCode, 2);
205
+ const errs = h.captured.error.join('\n');
206
+ assert.ok(errs.includes('not both'), `expected "not both" in error: ${errs}`);
207
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
208
+ } finally {
209
+ h.restoreLog();
210
+ }
211
+ });
212
+ });
213
+
214
+ // T6 — Layer 2: --no-ai under CLAUDECODE=1 without escape hatch → exit 2.
215
+ test('T6: create-terminal --no-ai under CLAUDECODE=1 exits 2 with human-only wording and no network call', async () => {
216
+ const h = buildHarness();
217
+ await withEnv({ CLAUDECODE: '1' }, async () => {
218
+ try {
219
+ await runArgv([
220
+ 'policy', 'tool', 'create-terminal',
221
+ '--no-ai',
222
+ '--name', 'X',
223
+ '--command-family', 'git',
224
+ '--field', 'command=git push*',
225
+ '--action', 'AUDIT',
226
+ ]);
227
+ assert.equal(process.exitCode, 2);
228
+ const errs = h.captured.error.join('\n');
229
+ assert.ok(errs.includes('CLAUDECODE=1'), `expected "CLAUDECODE=1" in error: ${errs}`);
230
+ assert.ok(
231
+ errs.includes('intended for interactive humans'),
232
+ `expected "intended for interactive humans" in error: ${errs}`
233
+ );
234
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
235
+ } finally {
236
+ h.restoreLog();
237
+ }
238
+ });
239
+ });
240
+
241
+ // T7 — Layer 2 escape hatch: with UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1, layer 2 is
242
+ // bypassed and the existing flag-path then errors out on missing --name.
243
+ test('T7: --no-ai under CLAUDECODE=1 + UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1 bypasses layer 2 and falls through to flag-path required-field error', async () => {
244
+ const h = buildHarness();
245
+ await withEnv({ CLAUDECODE: '1', UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE: '1' }, async () => {
246
+ try {
247
+ await runArgv(['policy', 'tool', 'create-terminal', '--no-ai']);
248
+ assert.equal(process.exitCode, 1, 'flag-path required-field error must use exit 1, not the guard exit 2');
249
+ const errs = h.captured.error.join('\n');
250
+ assert.ok(errs.includes('--name is required'), `expected "--name is required" in error: ${errs}`);
251
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
252
+ } finally {
253
+ h.restoreLog();
254
+ }
255
+ });
256
+ });
257
+
258
+ // T8 — Layer 2 does not interfere with --prompt under CLAUDECODE=1.
259
+ test('T8: create-terminal --prompt under CLAUDECODE=1 routes to the assist endpoint', async () => {
260
+ const h = buildHarness({
261
+ assistResponse: {
262
+ success: true,
263
+ form_updates: {
264
+ command_family: 'filesystem',
265
+ selected_field: 'command',
266
+ field_value: 'rm -rf*',
267
+ action: 'AUDIT',
268
+ name: 'X',
269
+ },
270
+ explanation: 'ok',
271
+ },
272
+ });
273
+ await withEnv({ CLAUDECODE: '1' }, async () => {
274
+ try {
275
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'block rm -rf', '--yes']);
276
+ assert.ok(
277
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
278
+ `expected assist POST, got: ${JSON.stringify(h.calls.posts.map((c) => c.path))}`
279
+ );
280
+ } finally {
281
+ h.restoreLog();
282
+ }
283
+ });
284
+ });
285
+
286
+ // T9 — Layer 3: create-terminal --help banner appears before "Usage:".
287
+ test('T9: create-terminal --help renders AI-ASSISTED banner before "Usage:"', async () => {
288
+ const h = buildHarness();
289
+ let captured = '';
290
+ const origWrite = process.stdout.write.bind(process.stdout);
291
+ process.stdout.write = (s) => { captured += s; return true; };
292
+ try {
293
+ await withEnv({}, async () => {
294
+ try {
295
+ await runArgv(['policy', 'tool', 'create-terminal', '--help']);
296
+ } catch (err) {
297
+ // commander.exitOverride() throws on --help with code 'commander.helpDisplayed'.
298
+ if (err && err.code && err.code !== 'commander.helpDisplayed') throw err;
299
+ }
300
+ });
301
+ } finally {
302
+ process.stdout.write = origWrite;
303
+ h.restoreLog();
304
+ }
305
+ const bannerIdx = captured.indexOf('AI-ASSISTED (preferred):');
306
+ const usageIdx = captured.indexOf('Usage:');
307
+ assert.notEqual(bannerIdx, -1, `expected "AI-ASSISTED (preferred):" banner in help output: ${captured}`);
308
+ assert.notEqual(usageIdx, -1, `expected "Usage:" in help output: ${captured}`);
309
+ assert.ok(bannerIdx < usageIdx, `banner (idx ${bannerIdx}) should precede Usage (idx ${usageIdx})`);
310
+ });
311
+
312
+ // T10 — Layer 3: create-mcp --help banner appears before "Usage:".
313
+ test('T10: create-mcp --help renders AI-ASSISTED banner before "Usage:"', async () => {
314
+ const h = buildHarness();
315
+ let captured = '';
316
+ const origWrite = process.stdout.write.bind(process.stdout);
317
+ process.stdout.write = (s) => { captured += s; return true; };
318
+ try {
319
+ await withEnv({}, async () => {
320
+ try {
321
+ await runArgv(['policy', 'tool', 'create-mcp', '--help']);
322
+ } catch (err) {
323
+ if (err && err.code && err.code !== 'commander.helpDisplayed') throw err;
324
+ }
325
+ });
326
+ } finally {
327
+ process.stdout.write = origWrite;
328
+ h.restoreLog();
329
+ }
330
+ const bannerIdx = captured.indexOf('AI-ASSISTED (preferred):');
331
+ const usageIdx = captured.indexOf('Usage:');
332
+ assert.notEqual(bannerIdx, -1, `expected "AI-ASSISTED (preferred):" banner in help output: ${captured}`);
333
+ assert.notEqual(usageIdx, -1, `expected "Usage:" in help output: ${captured}`);
334
+ assert.ok(bannerIdx < usageIdx, `banner (idx ${bannerIdx}) should precede Usage (idx ${usageIdx})`);
335
+ });
336
+
337
+ // T11 — Regression smoke: existing --prompt happy path still reaches assist endpoint.
338
+ test('T11: create-terminal --prompt without CLAUDECODE still routes to the assist endpoint (Phase-1 regression)', async () => {
339
+ const h = buildHarness({
340
+ assistResponse: {
341
+ success: true,
342
+ form_updates: {
343
+ command_family: 'git',
344
+ selected_field: 'command',
345
+ field_value: 'npm install*',
346
+ action: 'AUDIT',
347
+ name: 'X',
348
+ },
349
+ explanation: 'ok',
350
+ },
351
+ });
352
+ await withEnv({}, async () => {
353
+ try {
354
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'audit npm installs', '--yes']);
355
+ assert.ok(
356
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
357
+ `expected assist POST, got: ${JSON.stringify(h.calls.posts.map((c) => c.path))}`
358
+ );
359
+ } finally {
360
+ h.restoreLog();
361
+ }
362
+ });
363
+ });