unbound-cli 1.16.22 → 1.16.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.22",
3
+ "version": "1.16.23",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,13 +12,61 @@ const MAX_PROMPT_LEN = 1800;
12
12
  const TOOL_ACTIONS = ['AUDIT', 'BLOCK', 'WARN', 'REQUIRE_SLACK_APPROVAL'];
13
13
 
14
14
  const OUT_OF_SCOPE_KEYWORDS = Object.freeze([
15
- 'staging', 'production', 'prod', 'dev', 'qa', 'testing',
16
15
  'archived', 'archive',
17
16
  'business hour', 'after hour', 'outside hour', 'weekend',
18
- 'except', 'unless', 'but not',
17
+ 'except', 'unless', 'but not', 'non-prod', 'non prod',
19
18
  'private repo', 'public repo', 'private project', 'public project',
20
19
  ]);
21
20
 
21
+ // Terminal policies can scope by environment (cloud_*, database_* and
22
+ // build_operation carry an `environment` field); MCP tool policies cannot.
23
+ const MCP_OUT_OF_SCOPE_KEYWORDS = Object.freeze([
24
+ 'staging', 'production', 'prod', 'dev', 'qa', 'testing',
25
+ ...OUT_OF_SCOPE_KEYWORDS,
26
+ ]);
27
+
28
+ // Environment words checked against the prompt after the assist reply, whole
29
+ // words only ("product-catalog" is not "prod"). Words that are also everyday
30
+ // English ("npm test", "git stage", "live reload") count only before an
31
+ // environment noun or after in/to/on/against/into ("in the test environment",
32
+ // "deploy to live").
33
+ // `pre-?prod` precedes bare `prod` so "pre-prod" reads as pre-production, not
34
+ // as production; the regex alternates left to right.
35
+ const ENVIRONMENT_WORDS = 'production|pre[-_ ]?prod(?:uction)?|preprod|prod|prd|staging|stg|uat|dev|development|sandbox|sbx|qa|mainnet';
36
+ const CONTEXT_ENVIRONMENT_WORDS = 'test|testing|tst|live|stage|local|localhost';
37
+ const ENVIRONMENT_NOUNS = 'environment|env|cluster|namespace|servers?|database|db|account|project|stack|deployment|instances?';
38
+ // `\b` treats `_` as a word char and cannot see `prod_db`; the separator class
39
+ // can. A negated word is dropped: "non-prod" asks for the opposite of `prod`.
40
+ const ENVIRONMENT_WORD_RE = new RegExp(
41
+ `(?<!non[-_ ]?)(?<![a-z0-9])(${ENVIRONMENT_WORDS})(?![a-z0-9])`
42
+ + `|\\b(?:in|to|on|against|into)\\s+(?:the\\s+)?(${CONTEXT_ENVIRONMENT_WORDS})\\b`
43
+ + `|\\b(${CONTEXT_ENVIRONMENT_WORDS})\\s+(?:${ENVIRONMENT_NOUNS})\\b`,
44
+ 'gi'
45
+ );
46
+
47
+ // Environment token -> label. Source of truth: the gateway's deterministic
48
+ // classifier, src/services/classifier/deterministic/environmentTokens.ts.
49
+ const ENVIRONMENT_LABELS = Object.freeze({
50
+ prod: 'production', production: 'production', prd: 'production', live: 'production', mainnet: 'production',
51
+ stg: 'staging', staging: 'staging', stage: 'staging', uat: 'staging', preprod: 'staging', preproduction: 'staging',
52
+ dev: 'dev', development: 'dev', sandbox: 'dev', sbx: 'dev',
53
+ test: 'test', testing: 'test', tst: 'test',
54
+ local: 'local', localhost: 'local', '127.0.0.1': 'local',
55
+ });
56
+
57
+ // Aliases holding a separator, which tokenising would break apart.
58
+ const DOTTED_OR_HYPHENATED_ALIASES = Object.keys(ENVIRONMENT_LABELS).filter((k) => /[^a-z0-9]/.test(k));
59
+
60
+ // Pre-production spelled with a separator. The label table holds only the
61
+ // collapsed `preprod`, so these spellings are matched before tokenising.
62
+ const SEPARATED_PREPROD_RE = /(?<![a-z0-9])pre[^a-z0-9]+prod(?:uction)?(?![a-z0-9])/g;
63
+
64
+ // The label for a detected word, after collapsing separators so the spellings
65
+ // the prompt regex accepts ("pre-prod", "pre prod") reach the same entry.
66
+ function normaliseEnvironmentWord(word) {
67
+ return ENVIRONMENT_LABELS[word] ?? ENVIRONMENT_LABELS[String(word).replace(/[^a-z0-9]+/g, '')];
68
+ }
69
+
22
70
  // Single source of truth for --action override validation. Used both early
23
71
  // (in runTerminalPromptCreate, to fail before the assist POST) and late
24
72
  // (in mergeAiAndFlags, when applying the override). Returns the normalized
@@ -44,13 +92,15 @@ async function loadPrivileges() {
44
92
  return _privilegesCache;
45
93
  }
46
94
 
47
- function validatePromptPreflight(opts) {
95
+ function validatePromptPreflight(opts, keywords) {
48
96
  if (!opts || typeof opts.prompt !== 'string') {
49
97
  throw new Error('--prompt is required.');
50
98
  }
51
99
  const collapsed = opts.prompt
52
100
  .replace(/\n{2,}/g, '\n')
53
- .replace(/[^\x20-\x7e\n\t]/g, '');
101
+ // A stripped run becomes a space: a non-breaking space (routine in pasted
102
+ // text) would otherwise glue the words it separated into one.
103
+ .replace(/[^\x20-\x7e\n\t]+/g, ' ');
54
104
  const sanitizedPrompt = collapsed.trim();
55
105
  if (sanitizedPrompt.length > MAX_PROMPT_LEN) {
56
106
  const e = new Error(`Input is too long (max ${MAX_PROMPT_LEN} characters).`);
@@ -59,12 +109,28 @@ function validatePromptPreflight(opts) {
59
109
  }
60
110
  const warnings = [];
61
111
  const lower = sanitizedPrompt.toLowerCase();
62
- for (const token of OUT_OF_SCOPE_KEYWORDS) {
112
+ for (const token of keywords) {
63
113
  if (lower.includes(token)) warnings.push(token);
64
114
  }
65
115
  return { sanitizedPrompt, warnings };
66
116
  }
67
117
 
118
+ function outOfScopeWarning(word, scopes) {
119
+ return `Your prompt mentions \`${word}\`. Tool policies cannot scope by ${scopes} clauses. The endpoint will ignore those parts.`;
120
+ }
121
+
122
+ // `conditions` is the full AND list; selected_field / field_value only mirror
123
+ // conditions[0] for single-condition consumers.
124
+ function configFromAssist(f) {
125
+ const conditions = Array.isArray(f.conditions) ? f.conditions.filter((c) => c && c.field) : [];
126
+ if (!conditions.length) {
127
+ return f.selected_field ? { [f.selected_field]: String(f.field_value || '').trim() } : {};
128
+ }
129
+ const config = {};
130
+ for (const c of conditions) config[c.field] = String(c.value ?? '').trim();
131
+ return config;
132
+ }
133
+
68
134
  function mergeAiAndFlags(formUpdates, opts) {
69
135
  const f = formUpdates || {};
70
136
  const body = {
@@ -74,9 +140,7 @@ function mergeAiAndFlags(formUpdates, opts) {
74
140
  if (f.description) body.description = f.description;
75
141
  if (f.command_family) body.command_family = f.command_family;
76
142
 
77
- const field = f.selected_field || '';
78
- const value = f.field_value || '';
79
- body.config = field ? { [field]: value } : {};
143
+ body.config = configFromAssist(f);
80
144
 
81
145
  body.action = (f.action ? String(f.action).toUpperCase() : 'AUDIT');
82
146
 
@@ -103,6 +167,63 @@ function mergeAiAndFlags(formUpdates, opts) {
103
167
  return body;
104
168
  }
105
169
 
170
+ // True when a condition value already targets the environment `word`: one of
171
+ // its tokens is the word (branch 'production', 'make release-prod') or an alias
172
+ // of the same label (namespace '*prod*' or 'prod-*' for "production").
173
+ // Whole tokens only, so 'product-catalog' does not target "prod".
174
+ function targetsEnvironment(value, word) {
175
+ const label = normaliseEnvironmentWord(word);
176
+ const v = String(value).toLowerCase();
177
+ // An all-wildcard pattern matches every environment, so it targets this one.
178
+ if (/^\*+$/.test(v.trim())) return true;
179
+ // Tokens split on non-alphanumerics, so an alias holding one (`127.0.0.1`)
180
+ // never survives as a token and is matched on the whole value.
181
+ if (DOTTED_OR_HYPHENATED_ALIASES.some((a) => v.includes(a) && ENVIRONMENT_LABELS[a] === label)) {
182
+ return true;
183
+ }
184
+ // `pre-prod` is pre-production however it is spelled, so it is consumed before
185
+ // tokenising: the `prod` left behind would otherwise read as production.
186
+ let preprod = false;
187
+ const tokens = v.replace(SEPARATED_PREPROD_RE, () => { preprod = true; return ' '; }).split(/[^a-z0-9]+/);
188
+ if (preprod && label === ENVIRONMENT_LABELS.preprod) return true;
189
+ return tokens.some(
190
+ (token) => token === word || (label !== undefined && ENVIRONMENT_LABELS[token] === label)
191
+ );
192
+ }
193
+
194
+ // Only some families (cloud_*, database_*, build_operation) have an
195
+ // `environment` field, so the resolved policy can silently drop or narrow an
196
+ // environment the prompt named. A word some condition already targets was
197
+ // applied, so it does not warn. Returns true when it warned.
198
+ function warnEnvironmentScope(prompt, body) {
199
+ const environment = String(body.config.environment ?? '').trim();
200
+ // An `environment` condition decides scope on its own. Another field holding
201
+ // the word (`service: prod-api`) does not widen it, so it cannot vouch here.
202
+ // Present-but-blank still decides: a blank condition scopes to nothing.
203
+ const values = Object.prototype.hasOwnProperty.call(body.config, 'environment')
204
+ ? [environment]
205
+ : Object.values(body.config);
206
+ const words = new Set([...prompt.matchAll(ENVIRONMENT_WORD_RE)].map((m) => (m[1] || m[2] || m[3]).toLowerCase()));
207
+ let warned = environment ? warnUnrecognisedEnvironment(environment) : false;
208
+ for (const word of words) {
209
+ if (values.some((v) => targetsEnvironment(v, word))) continue;
210
+ const scope = environment
211
+ ? `this policy's environment condition is \`${environment}\`, so it does not apply in ${word}`
212
+ : `this ${body.command_family || 'terminal'} policy has no environment condition, so it applies in all environments`;
213
+ output.warn(`Your prompt mentions \`${word}\`, but ${scope}.`);
214
+ warned = true;
215
+ }
216
+ return warned;
217
+ }
218
+
219
+ function warnUnrecognisedEnvironment(environment) {
220
+ if (String(environment).replace(/\*/g, '').trim().toLowerCase() !== 'qa') return false;
221
+ const labels = [...new Set(Object.values(ENVIRONMENT_LABELS))];
222
+ const aliases = Object.keys(ENVIRONMENT_LABELS).filter((token) => ENVIRONMENT_LABELS[token] !== token);
223
+ output.warn(`\`qa\` is not a recognised environment label (recognised: ${labels.join(', ')}, and their aliases ${aliases.join(', ')}), so this policy may not match.`);
224
+ return true;
225
+ }
226
+
106
227
  function colorAction(action) {
107
228
  const colors = output.colors;
108
229
  if (!action) return '(unspecified)';
@@ -117,10 +238,8 @@ function renderTerminalPreview(body, explanation) {
117
238
  ? `groups: ${body.scope_user_group_ids.join(', ')}`
118
239
  : '(org-wide)';
119
240
  const family = body.command_family || '(unspecified)';
120
- const cfg = body.config || {};
121
- const cfgKeys = Object.keys(cfg);
122
- const fieldKey = cfgKeys[0] || '(unspecified)';
123
- const pattern = cfgKeys.length ? String(cfg[cfgKeys[0]]) : '(unspecified)';
241
+ const cfgEntries = Object.entries(body.config || {});
242
+ const matches = cfgEntries.length ? cfgEntries : [['(unspecified)', '(unspecified)']];
124
243
 
125
244
  // Header goes to stdout (NOT output.info, which writes to stderr) so the
126
245
  // whole preview — header, keyValue rows, explanation — is captured by a
@@ -134,8 +253,11 @@ function renderTerminalPreview(body, explanation) {
134
253
  if (body.description) rows.push(['Description', body.description]);
135
254
  rows.push(['Type', body.policy_type]);
136
255
  rows.push(['Command family', family]);
137
- rows.push(['Field', fieldKey]);
138
- rows.push(['Pattern', pattern]);
256
+ if (matches.length > 1) rows.push(['Conditions', output.colors.dim('all must match (AND)')]);
257
+ for (const [key, value] of matches) {
258
+ rows.push(['Field', key]);
259
+ rows.push(['Pattern', String(value)]);
260
+ }
139
261
  rows.push(['Action', colorAction(body.action)]);
140
262
  if (body.custom_message) rows.push(['Custom message', body.custom_message]);
141
263
  rows.push(['Scope (groups)', scope]);
@@ -204,6 +326,10 @@ function confirmCreate() {
204
326
  return promptYesNo('Create policy? [Y/n] ', true);
205
327
  }
206
328
 
329
+ function confirmCreateAnyway() {
330
+ return promptYesNo('Create policy anyway? [y/N] ', false);
331
+ }
332
+
207
333
  function confirmContinue() {
208
334
  return promptYesNo('Continue anyway? The endpoint will ignore those parts. [y/N] ', false);
209
335
  }
@@ -237,9 +363,9 @@ async function runTerminalPromptCreate(opts) {
237
363
  // care about the throw side-effect; mergeAiAndFlags re-validates and applies.
238
364
  validateActionOverride(opts.action);
239
365
 
240
- const { sanitizedPrompt, warnings } = validatePromptPreflight(opts);
366
+ const { sanitizedPrompt, warnings } = validatePromptPreflight(opts, OUT_OF_SCOPE_KEYWORDS);
241
367
  for (const w of warnings) {
242
- output.warn(`Your prompt mentions \`${w}\`. Tool policies cannot scope by environment / project / time / exception clauses. The endpoint will ignore those parts.`);
368
+ output.warn(outOfScopeWarning(w, 'project / time / exception'));
243
369
  }
244
370
  if (warnings.length && !opts.yes && !opts.json) {
245
371
  const cont = await confirmContinue();
@@ -314,10 +440,11 @@ async function runTerminalPromptCreate(opts) {
314
440
  }
315
441
 
316
442
  if (!opts.json) renderTerminalPreview(body, response.explanation);
443
+ const warned = warnEnvironmentScope(sanitizedPrompt, body);
317
444
 
318
445
  let confirmed = true;
319
446
  if (!opts.yes && !opts.json) {
320
- confirmed = await confirmCreate();
447
+ confirmed = await (warned ? confirmCreateAnyway() : confirmCreate());
321
448
  }
322
449
  return { body, confirmed, explanation: response.explanation };
323
450
  }
@@ -402,9 +529,9 @@ async function runMcpPromptCreate(opts) {
402
529
 
403
530
  validateActionOverride(opts.action);
404
531
 
405
- const { sanitizedPrompt, warnings } = validatePromptPreflight(opts);
532
+ const { sanitizedPrompt, warnings } = validatePromptPreflight(opts, MCP_OUT_OF_SCOPE_KEYWORDS);
406
533
  for (const w of warnings) {
407
- output.warn(`Your prompt mentions \`${w}\`. Tool policies cannot scope by environment / project / time / exception clauses. The endpoint will ignore those parts.`);
534
+ output.warn(outOfScopeWarning(w, 'environment / project / time / exception'));
408
535
  }
409
536
  if (warnings.length && !opts.yes && !opts.json) {
410
537
  const cont = await confirmContinue();
@@ -494,6 +621,7 @@ async function runMcpPromptCreate(opts) {
494
621
  module.exports = {
495
622
  MAX_PROMPT_LEN,
496
623
  OUT_OF_SCOPE_KEYWORDS,
624
+ MCP_OUT_OF_SCOPE_KEYWORDS,
497
625
  validatePromptPreflight,
498
626
  mergeAiAndFlags,
499
627
  renderTerminalPreview,
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const {
5
5
  validatePromptPreflight,
6
6
  OUT_OF_SCOPE_KEYWORDS,
7
+ MCP_OUT_OF_SCOPE_KEYWORDS,
7
8
  MAX_PROMPT_LEN,
8
9
  } = require('../src/lib/policy-ai-assist');
9
10
 
@@ -11,52 +12,85 @@ const {
11
12
  // with backend-equivalent wording.
12
13
 
13
14
  test('preflight: prompt of 1799 chars passes', () => {
14
- const r = validatePromptPreflight({ prompt: 'a'.repeat(1799) });
15
+ const r = validatePromptPreflight({ prompt: 'a'.repeat(1799) }, OUT_OF_SCOPE_KEYWORDS);
15
16
  assert.equal(r.warnings.length, 0);
16
17
  });
17
18
 
18
19
  test('preflight: prompt of 1800 chars passes (boundary)', () => {
19
- const r = validatePromptPreflight({ prompt: 'a'.repeat(MAX_PROMPT_LEN) });
20
+ const r = validatePromptPreflight({ prompt: 'a'.repeat(MAX_PROMPT_LEN) }, OUT_OF_SCOPE_KEYWORDS);
20
21
  assert.equal(r.warnings.length, 0);
21
22
  });
22
23
 
23
24
  test('preflight: prompt of 1801 chars rejects with verbatim wording', () => {
24
25
  assert.throws(
25
- () => validatePromptPreflight({ prompt: 'a'.repeat(1801) }),
26
+ () => validatePromptPreflight({ prompt: 'a'.repeat(1801) }, OUT_OF_SCOPE_KEYWORDS),
26
27
  new RegExp(`^Error: Input is too long \\(max ${MAX_PROMPT_LEN} characters\\)\\.$`)
27
28
  );
28
29
  });
29
30
 
30
- // §6.2 step 4: parametrized over the keyword list — every token surfaces a warning.
31
+ // §6.2 step 4: parametrized over the keyword lists — every token surfaces a warning.
31
32
  for (const token of OUT_OF_SCOPE_KEYWORDS) {
32
33
  test(`preflight: out-of-scope keyword "${token}" produces a warning`, () => {
33
- const r = validatePromptPreflight({ prompt: `block X in ${token} mode` });
34
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, OUT_OF_SCOPE_KEYWORDS);
34
35
  assert.ok(r.warnings.includes(token), `expected warning for ${token}, got ${JSON.stringify(r.warnings)}`);
35
36
  });
36
37
  }
37
38
 
39
+ for (const token of MCP_OUT_OF_SCOPE_KEYWORDS) {
40
+ test(`preflight: MCP out-of-scope keyword "${token}" produces a warning`, () => {
41
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, MCP_OUT_OF_SCOPE_KEYWORDS);
42
+ assert.ok(r.warnings.includes(token), `expected warning for ${token}, got ${JSON.stringify(r.warnings)}`);
43
+ });
44
+ }
45
+
46
+ // Terminal policies can scope by environment, so these only warn for MCP.
47
+ for (const token of ['staging', 'production', 'prod', 'dev', 'qa', 'testing']) {
48
+ test(`preflight: environment keyword "${token}" does not warn with the terminal list`, () => {
49
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, OUT_OF_SCOPE_KEYWORDS);
50
+ assert.deepEqual(r.warnings, []);
51
+ });
52
+ }
53
+
38
54
  // §6.2 step 5: collapse runs of >=2 newlines to a single newline.
39
55
  test('preflight: collapses three consecutive newlines to one', () => {
40
- const r = validatePromptPreflight({ prompt: 'line1\n\n\nline2' });
56
+ const r = validatePromptPreflight({ prompt: 'line1\n\n\nline2' }, OUT_OF_SCOPE_KEYWORDS);
41
57
  assert.equal(r.sanitizedPrompt, 'line1\nline2');
42
58
  });
43
59
 
44
60
  test('preflight: collapses two consecutive newlines to one', () => {
45
- const r = validatePromptPreflight({ prompt: 'a\n\nb' });
61
+ const r = validatePromptPreflight({ prompt: 'a\n\nb' }, OUT_OF_SCOPE_KEYWORDS);
46
62
  assert.equal(r.sanitizedPrompt, 'a\nb');
47
63
  });
48
64
 
49
65
  test('preflight: a single newline is left alone', () => {
50
- const r = validatePromptPreflight({ prompt: 'a\nb' });
66
+ const r = validatePromptPreflight({ prompt: 'a\nb' }, OUT_OF_SCOPE_KEYWORDS);
51
67
  assert.equal(r.sanitizedPrompt, 'a\nb');
52
68
  });
53
69
 
70
+ // A stripped character must not join the words it separated: pasted text
71
+ // carries non-breaking spaces, and "inproduction" names no environment.
72
+ test('preflight: a non-breaking space becomes a space, not nothing', () => {
73
+ const r = validatePromptPreflight({ prompt: 'block terraform destroy in production' }, OUT_OF_SCOPE_KEYWORDS);
74
+ assert.equal(r.sanitizedPrompt, 'block terraform destroy in production');
75
+ });
76
+
77
+ // A negated environment is an exception clause, which policies cannot express.
78
+ for (const prompt of ['block schema drops in non-prod', 'block schema drops in non production', 'block schema drops in non-production']) {
79
+ test(`preflight: "${prompt}" reports a negated environment as out of scope`, () => {
80
+ const r = validatePromptPreflight({ prompt }, OUT_OF_SCOPE_KEYWORDS);
81
+ assert.ok(r.warnings.some((w) => w.startsWith('non')), `expected a negation warning, got ${JSON.stringify(r.warnings)}`);
82
+ });
83
+ }
84
+
85
+ test('preflight: "non-interactive" is not a negated environment', () => {
86
+ const r = validatePromptPreflight({ prompt: 'audit non-interactive scripts' }, OUT_OF_SCOPE_KEYWORDS);
87
+ assert.deepEqual(r.warnings, []);
88
+ });
89
+
54
90
  // §6.2: warning is informational only; under --yes the caller logs it and
55
91
  // continues. The helper itself never throws for warnings — it just reports.
56
92
  test('preflight: warnings do not throw', () => {
57
- // Use a keyword that is NOT a substring of "production" (the keyword set
58
- // contains "prod" which would also match): pick a non-overlapping one.
59
- const r = validatePromptPreflight({ prompt: 'block weekend pushes' });
93
+ const r = validatePromptPreflight({ prompt: 'block weekend pushes' }, OUT_OF_SCOPE_KEYWORDS);
60
94
  assert.ok(r.warnings.includes('weekend'));
61
95
  // No throw — the function returned and we got here.
62
96
  });
@@ -94,6 +94,20 @@ async function runCreate(argv) {
94
94
  await program.parseAsync(full);
95
95
  }
96
96
 
97
+ // Answer every readline question (promptYesNo) with `answer`; records the
98
+ // questions asked.
99
+ function stubReadline(answer) {
100
+ const readline = require('readline');
101
+ const origCreate = readline.createInterface;
102
+ const questions = [];
103
+ readline.createInterface = () => ({
104
+ on() {},
105
+ question(q, cb) { questions.push(q); cb(answer); },
106
+ close() {},
107
+ });
108
+ return { questions, restore: () => { readline.createInterface = origCreate; } };
109
+ }
110
+
97
111
  beforeEach(() => {
98
112
  process.exitCode = 0;
99
113
  });
@@ -470,6 +484,112 @@ test('happy path: builds policy body and POSTs to /command-policies/', async ()
470
484
  }
471
485
  });
472
486
 
487
+ // --- Multi-condition replies (form_updates.conditions) ------------------------
488
+ // The assist endpoint returns every AND condition in `conditions`; selected_field
489
+ // and field_value only mirror conditions[0], which need not be the environment.
490
+
491
+ const TERRAFORM_PROD_REPLY = {
492
+ success: true,
493
+ form_updates: {
494
+ command_family: 'cloud_destroy',
495
+ selected_field: 'provider',
496
+ field_value: '*terraform*',
497
+ conditions: [
498
+ { field: 'provider', value: '*terraform*' },
499
+ { field: 'environment', value: '*prod*' },
500
+ ],
501
+ action: 'block',
502
+ name: 'Block terraform destroy in production',
503
+ },
504
+ explanation: 'terraform destroy, production only',
505
+ };
506
+
507
+ test('conditions: every AND condition reaches the create body and the preview', async () => {
508
+ const h = buildHarness({ assistResponse: TERRAFORM_PROD_REPLY });
509
+ try {
510
+ await runCreate([
511
+ '--prompt', 'block terraform destroy in production',
512
+ '--custom-message', 'Not in prod.', '--yes',
513
+ ]);
514
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
515
+ assert.ok(createCall, 'create POST should have fired');
516
+ assert.deepEqual(createCall.body.config, { provider: '*terraform*', environment: '*prod*' });
517
+ const out = h.captured.stdout.join('\n');
518
+ const preview = out.slice(0, out.indexOf('AI assist explanation'));
519
+ for (const text of ['provider', '*terraform*', 'environment', '*prod*']) {
520
+ assert.ok(preview.includes(text), `preview missing ${text}: ${preview}`);
521
+ }
522
+ const andIdx = preview.indexOf('all must match (AND)');
523
+ assert.ok(andIdx !== -1 && andIdx < preview.indexOf('provider'), `AND line must precede the conditions: ${preview}`);
524
+ assert.deepEqual(h.captured.warn, [], 'environment is applied, so no environment warning');
525
+ } finally {
526
+ h.restoreLog();
527
+ }
528
+ });
529
+
530
+ test('conditions: an empty conditions list falls back to selected_field / field_value', async () => {
531
+ const h = buildHarness({
532
+ assistResponse: {
533
+ success: true,
534
+ form_updates: {
535
+ command_family: 'git', selected_field: 'command', field_value: 'git push*',
536
+ conditions: [], action: 'AUDIT', name: 'Audit pushes',
537
+ },
538
+ explanation: 'ok',
539
+ },
540
+ });
541
+ try {
542
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
543
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
544
+ assert.ok(createCall, 'create POST should have fired');
545
+ assert.deepEqual(createCall.body.config, { command: 'git push*' });
546
+ } finally {
547
+ h.restoreLog();
548
+ }
549
+ });
550
+
551
+ for (const [source, conditions] of [['conditions', [{ field: 'command', value: ' git push* ' }]], ['selected_field', undefined]]) {
552
+ test(`conditions: values from ${source} are trimmed, as the dashboard does`, async () => {
553
+ const h = buildHarness({
554
+ assistResponse: {
555
+ success: true,
556
+ form_updates: {
557
+ command_family: 'git', selected_field: 'command', field_value: ' git push* ',
558
+ conditions, action: 'AUDIT', name: 'Audit pushes',
559
+ },
560
+ explanation: 'ok',
561
+ },
562
+ });
563
+ try {
564
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
565
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
566
+ assert.ok(createCall, 'create POST should have fired');
567
+ assert.deepEqual(createCall.body.config, { command: 'git push*' });
568
+ } finally {
569
+ h.restoreLog();
570
+ }
571
+ });
572
+ }
573
+
574
+ // --field / --config stay mutually exclusive with --prompt: they never merge
575
+ // into, or override, the AI's conditions.
576
+ for (const [flag, value] of [['--field', 'environment=*staging*'], ['--config', '{"environment":"*staging*"}']]) {
577
+ test(`conditions: --prompt + ${flag} is still rejected before the assist call`, async () => {
578
+ const h = buildHarness({ assistResponse: TERRAFORM_PROD_REPLY });
579
+ try {
580
+ await runCreate(['--prompt', 'block terraform destroy in production', flag, value, '--yes']);
581
+ assert.equal(process.exitCode, 1);
582
+ assert.ok(
583
+ h.captured.error.includes('Pass --prompt for AI-assist or the field flags for explicit creation, not both.'),
584
+ `expected mutex error, got: ${JSON.stringify(h.captured.error)}`
585
+ );
586
+ assert.equal(h.calls.posts.length, 0, 'neither assist nor create should fire');
587
+ } finally {
588
+ h.restoreLog();
589
+ }
590
+ });
591
+ }
592
+
473
593
  // --- (b.1) Greptile follow-up regressions -------------------------------------
474
594
 
475
595
  test('AI returns BLOCK without --custom-message → exit 2 with guard message', async () => {
@@ -758,7 +878,7 @@ test('routing: assist succeeds but create POST 401 → exit 3 + admin-required w
758
878
 
759
879
  // --- (d) --yes + out-of-scope keyword: warns AND proceeds ---------------------
760
880
 
761
- test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint', async () => {
881
+ test('out-of-scope: --yes + "weekend" warns but still calls the assist endpoint', async () => {
762
882
  const h = buildHarness({
763
883
  assistResponse: {
764
884
  success: true,
@@ -770,10 +890,14 @@ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint'
770
890
  },
771
891
  });
772
892
  try {
773
- await runCreate(['--prompt', 'block staging deploys', '--yes']);
893
+ await runCreate(['--prompt', 'block git pushes on weekends', '--yes']);
774
894
  assert.ok(
775
- h.captured.warn.some((m) => m.includes('`staging`')),
776
- `expected staging warning, got: ${JSON.stringify(h.captured.warn)}`
895
+ h.captured.warn.some((m) => m.includes('`weekend`')),
896
+ `expected weekend warning, got: ${JSON.stringify(h.captured.warn)}`
897
+ );
898
+ assert.ok(
899
+ !h.captured.warn.some((m) => m.includes('environment')),
900
+ `terminal warning must not claim environment is out of scope, got: ${JSON.stringify(h.captured.warn)}`
777
901
  );
778
902
  assert.ok(
779
903
  h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
@@ -784,6 +908,627 @@ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint'
784
908
  }
785
909
  });
786
910
 
911
+ test('out-of-scope: without --yes, "except" asks to continue; "n" aborts before the assist call', async () => {
912
+ const h = buildHarness();
913
+ const rl = stubReadline('n');
914
+ try {
915
+ await runCreate(['--prompt', 'block git pushes except on main']);
916
+ assert.equal(process.exitCode, 0);
917
+ assert.ok(
918
+ h.captured.warn.some((m) => m.includes('`except`')),
919
+ `expected except warning, got: ${JSON.stringify(h.captured.warn)}`
920
+ );
921
+ assert.equal(rl.questions.length, 1);
922
+ assert.ok(rl.questions[0].includes('Continue anyway?'), `got: ${JSON.stringify(rl.questions)}`);
923
+ assert.equal(h.calls.posts.length, 0, 'assist must not be called after the user aborts');
924
+ } finally {
925
+ rl.restore();
926
+ h.restoreLog();
927
+ }
928
+ });
929
+
930
+ // Terminal policies can scope by environment (cloud_*, database_* and
931
+ // build_operation carry an `environment` field), so environment words are in
932
+ // scope here.
933
+ for (const [word, prompt, environment] of [
934
+ ['production', 'audit aws deletes in production', '*prod*'],
935
+ ['prod', 'block terraform destroy against prod', '*prod*'],
936
+ ['staging', 'audit database drops in staging', '*stg*'],
937
+ ]) {
938
+ test(`in-scope: "${word}" in a terminal prompt neither warns nor asks to continue`, async () => {
939
+ const h = buildHarness({
940
+ assistResponse: {
941
+ success: true,
942
+ form_updates: {
943
+ command_family: 'cloud_destroy', selected_field: 'environment',
944
+ field_value: environment, action: 'AUDIT', name: `Audit ${word} cloud deletes`,
945
+ },
946
+ explanation: 'ok',
947
+ },
948
+ });
949
+ const rl = stubReadline('y');
950
+ try {
951
+ await runCreate(['--prompt', prompt]);
952
+ assert.equal(process.exitCode, 0);
953
+ assert.deepEqual(h.captured.warn, []);
954
+ assert.deepEqual(rl.questions, ['Create policy? [Y/n] '], 'only the create confirmation should be asked');
955
+ const assist = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/assist/');
956
+ assert.ok(assist, 'assist endpoint should be called');
957
+ assert.equal(assist.body.user_input, prompt);
958
+ const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
959
+ assert.ok(create, 'create POST should fire after confirmation');
960
+ assert.deepEqual(create.body.config, { environment });
961
+ } finally {
962
+ rl.restore();
963
+ h.restoreLog();
964
+ }
965
+ });
966
+ }
967
+
968
+ // Only some families carry `environment`; when the resolved policy has none,
969
+ // an environment named in the prompt was dropped. Warn and, interactively,
970
+ // default the confirmation to No; never block --yes / --json.
971
+ const GIT_PUSH_REPLY = {
972
+ success: true,
973
+ form_updates: {
974
+ command_family: 'git_action', selected_field: 'operation', field_value: 'push',
975
+ conditions: [{ field: 'operation', value: 'push' }],
976
+ action: 'block', name: 'Block git push',
977
+ },
978
+ explanation: 'ok',
979
+ };
980
+
981
+ for (const [mode, flags] of [['interactive', []], ['--yes', ['--yes']], ['--json', ['--json']]]) {
982
+ test(`environment (${mode}): an environment the resolved family cannot scope by warns; policy still created`, async () => {
983
+ const h = buildHarness({ assistResponse: GIT_PUSH_REPLY });
984
+ const rl = stubReadline('y');
985
+ try {
986
+ await runCreate(['--prompt', 'block git push to production', '--custom-message', 'No.', ...flags]);
987
+ assert.equal(process.exitCode, 0);
988
+ assert.deepEqual(h.captured.warn, [
989
+ 'Your prompt mentions `production`, but this git_action policy has no environment condition, so it applies in all environments.',
990
+ ]);
991
+ const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
992
+ assert.ok(create, 'create POST should still fire');
993
+ assert.deepEqual(create.body.config, { operation: 'push' });
994
+ assert.deepEqual(rl.questions, mode === 'interactive' ? ['Create policy anyway? [y/N] '] : []);
995
+ if (mode === '--json') {
996
+ assert.deepEqual(JSON.parse(h.captured.stdout.join('\n')), { id: 1, name: 'ok' }, 'stdout stays pure JSON');
997
+ }
998
+ } finally {
999
+ rl.restore();
1000
+ h.restoreLog();
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ test('environment (interactive): Enter on the warned confirmation aborts; no create POST', async () => {
1006
+ const h = buildHarness({ assistResponse: GIT_PUSH_REPLY });
1007
+ const rl = stubReadline('');
1008
+ try {
1009
+ await runCreate(['--prompt', 'block git push to production', '--custom-message', 'No.']);
1010
+ assert.equal(process.exitCode, 0);
1011
+ assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
1012
+ assert.ok(h.captured.warn.includes('Aborted.'), `expected Aborted., got: ${JSON.stringify(h.captured.warn)}`);
1013
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
1014
+ } finally {
1015
+ rl.restore();
1016
+ h.restoreLog();
1017
+ }
1018
+ });
1019
+
1020
+ test('environment: warns once for each prompt environment no condition applies', async () => {
1021
+ const h = buildHarness({
1022
+ assistResponse: {
1023
+ success: true,
1024
+ form_updates: {
1025
+ command_family: 'git_action', selected_field: 'operation', field_value: 'push',
1026
+ conditions: [
1027
+ { field: 'operation', value: 'push' },
1028
+ { field: 'branch', value: '*dev*' },
1029
+ ],
1030
+ action: 'block', name: 'Block git push to dev',
1031
+ },
1032
+ explanation: 'ok',
1033
+ },
1034
+ });
1035
+ try {
1036
+ await runCreate([
1037
+ '--prompt', 'block git push to dev, staging and production; production especially',
1038
+ '--custom-message', 'No.', '--yes',
1039
+ ]);
1040
+ assert.equal(process.exitCode, 0);
1041
+ assert.deepEqual(h.captured.warn, [
1042
+ 'Your prompt mentions `staging`, but this git_action policy has no environment condition, so it applies in all environments.',
1043
+ 'Your prompt mentions `production`, but this git_action policy has no environment condition, so it applies in all environments.',
1044
+ ]);
1045
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
1046
+ } finally {
1047
+ h.restoreLog();
1048
+ }
1049
+ });
1050
+
1051
+ for (const [mode, flags] of [['--yes', ['--yes']], ['interactive', []]]) {
1052
+ test(`environment (${mode}): an environment condition of qa warns that qa is not a recognised label`, async () => {
1053
+ const h = buildHarness({
1054
+ assistResponse: {
1055
+ success: true,
1056
+ form_updates: {
1057
+ command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
1058
+ conditions: [
1059
+ { field: 'provider', value: '*terraform*' },
1060
+ { field: 'environment', value: '*QA*' },
1061
+ ],
1062
+ action: 'audit', name: 'Audit terraform destroy in qa',
1063
+ },
1064
+ explanation: 'ok',
1065
+ },
1066
+ });
1067
+ const rl = stubReadline('y');
1068
+ try {
1069
+ await runCreate(['--prompt', 'audit terraform destroy in qa', ...flags]);
1070
+ assert.equal(process.exitCode, 0);
1071
+ assert.deepEqual(h.captured.warn, [
1072
+ '`qa` is not a recognised environment label (recognised: production, staging, dev, test, local, and their aliases prod, prd, live, mainnet, stg, stage, uat, preprod, preproduction, development, sandbox, sbx, testing, tst, localhost, 127.0.0.1), so this policy may not match.',
1073
+ ]);
1074
+ assert.deepEqual(rl.questions, mode === 'interactive' ? ['Create policy anyway? [y/N] '] : []);
1075
+ const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
1076
+ assert.ok(create, 'create POST should still fire');
1077
+ assert.equal(create.body.config.environment, '*QA*');
1078
+ } finally {
1079
+ rl.restore();
1080
+ h.restoreLog();
1081
+ }
1082
+ });
1083
+ }
1084
+
1085
+ // An environment condition for a different environment than the prompt names.
1086
+ test('environment (interactive): a prompt environment the environment condition does not cover warns; Enter aborts', async () => {
1087
+ const h = buildHarness({
1088
+ assistResponse: {
1089
+ success: true,
1090
+ form_updates: {
1091
+ command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
1092
+ conditions: [
1093
+ { field: 'provider', value: '*terraform*' },
1094
+ { field: 'environment', value: '*prod*' },
1095
+ ],
1096
+ action: 'block', name: 'Block terraform destroy in production',
1097
+ },
1098
+ explanation: 'ok',
1099
+ },
1100
+ });
1101
+ const rl = stubReadline('');
1102
+ try {
1103
+ await runCreate(['--prompt', 'block terraform destroy in staging and production', '--custom-message', 'No.']);
1104
+ assert.equal(process.exitCode, 0);
1105
+ assert.deepEqual(h.captured.warn, [
1106
+ "Your prompt mentions `staging`, but this policy's environment condition is `*prod*`, so it does not apply in staging.",
1107
+ 'Aborted.',
1108
+ ]);
1109
+ assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
1110
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
1111
+ } finally {
1112
+ rl.restore();
1113
+ h.restoreLog();
1114
+ }
1115
+ });
1116
+
1117
+ // An environment condition decides scope by itself, so another field holding
1118
+ // the prompt's word must not count as evidence that the scope was applied.
1119
+ test('environment: another condition holding the word does not excuse a mismatched environment', async () => {
1120
+ const h = buildHarness({
1121
+ assistResponse: {
1122
+ success: true,
1123
+ form_updates: {
1124
+ command_family: 'cloud_destroy', selected_field: 'service', field_value: '*prod-api*',
1125
+ conditions: [
1126
+ { field: 'service', value: '*prod-api*' },
1127
+ { field: 'environment', value: '*staging*' },
1128
+ ],
1129
+ action: 'block', name: 'Block prod-api destroy',
1130
+ },
1131
+ explanation: 'ok',
1132
+ },
1133
+ });
1134
+ const rl = stubReadline('');
1135
+ try {
1136
+ await runCreate(['--prompt', 'block cloud destroy in prod', '--custom-message', 'No.']);
1137
+ assert.equal(process.exitCode, 0);
1138
+ assert.deepEqual(h.captured.warn, [
1139
+ "Your prompt mentions `prod`, but this policy's environment condition is `*staging*`, so it does not apply in prod.",
1140
+ 'Aborted.',
1141
+ ]);
1142
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
1143
+ } finally {
1144
+ rl.restore();
1145
+ h.restoreLog();
1146
+ }
1147
+ });
1148
+
1149
+ // `pre-prod` is pre-production, which maps to staging, never to production.
1150
+ test('environment: pre-prod given a production condition warns', async () => {
1151
+ const h = buildHarness({
1152
+ assistResponse: {
1153
+ success: true,
1154
+ form_updates: {
1155
+ command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
1156
+ conditions: [
1157
+ { field: 'provider', value: '*terraform*' },
1158
+ { field: 'environment', value: '*prod*' },
1159
+ ],
1160
+ action: 'block', name: 'Block terraform destroy',
1161
+ },
1162
+ explanation: 'ok',
1163
+ },
1164
+ });
1165
+ const rl = stubReadline('');
1166
+ try {
1167
+ await runCreate(['--prompt', 'block terraform destroy in pre-prod', '--custom-message', 'No.']);
1168
+ assert.equal(process.exitCode, 0);
1169
+ assert.deepEqual(h.captured.warn, [
1170
+ "Your prompt mentions `pre-prod`, but this policy's environment condition is `*prod*`, so it does not apply in pre-prod.",
1171
+ 'Aborted.',
1172
+ ]);
1173
+ } finally {
1174
+ rl.restore();
1175
+ h.restoreLog();
1176
+ }
1177
+ });
1178
+
1179
+ // A separated pre-production condition does not cover production, however it is
1180
+ // spelled: the `prod` inside it must not read as the environment asked for.
1181
+ for (const value of ['*pre-prod*', '*pre_prod*', '*pre production*', '*pre-production*']) {
1182
+ test(`environment: a production prompt given ${value} warns`, async () => {
1183
+ const h = buildHarness({
1184
+ assistResponse: {
1185
+ success: true,
1186
+ form_updates: {
1187
+ command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
1188
+ conditions: [
1189
+ { field: 'provider', value: '*terraform*' },
1190
+ { field: 'environment', value },
1191
+ ],
1192
+ action: 'block', name: 'Block terraform destroy',
1193
+ },
1194
+ explanation: 'ok',
1195
+ },
1196
+ });
1197
+ const rl = stubReadline('');
1198
+ try {
1199
+ await runCreate(['--prompt', 'block terraform destroy in production', '--custom-message', 'No.']);
1200
+ assert.equal(process.exitCode, 0);
1201
+ assert.deepEqual(h.captured.warn, [
1202
+ `Your prompt mentions \`production\`, but this policy's environment condition is \`${value}\`, so it does not apply in production.`,
1203
+ 'Aborted.',
1204
+ ]);
1205
+ } finally {
1206
+ rl.restore();
1207
+ h.restoreLog();
1208
+ }
1209
+ });
1210
+ }
1211
+
1212
+ // A separator inside an alias survives, so these spellings are already scoped.
1213
+ for (const [prompt, field, value] of [
1214
+ ['block terraform destroy in pre-prod', 'environment', '*preprod*'],
1215
+ ['block terraform destroy in pre-prod', 'environment', '*pre-prod*'],
1216
+ ['block terraform destroy in pre-prod', 'environment', '*staging*'],
1217
+ ['block terraform destroy in staging', 'environment', '*pre-prod*'],
1218
+ ['block db writes in local', 'host', '127.0.0.1'],
1219
+ ['block db writes in localhost', 'host', '127.0.0.1'],
1220
+ ['block terraform destroy in production', 'environment', '*'],
1221
+ ]) {
1222
+ test(`environment: "${prompt}" with ${field} ${value} does not warn`, async () => {
1223
+ const h = buildHarness({
1224
+ assistResponse: {
1225
+ success: true,
1226
+ form_updates: {
1227
+ command_family: 'database_write', selected_field: field, field_value: value,
1228
+ conditions: [{ field, value }],
1229
+ action: 'block', name: 'Block it',
1230
+ },
1231
+ explanation: 'ok',
1232
+ },
1233
+ });
1234
+ try {
1235
+ await runCreate(['--prompt', prompt, '--custom-message', 'No.', '--yes']);
1236
+ assert.deepEqual(h.captured.warn, []);
1237
+ } finally {
1238
+ h.restoreLog();
1239
+ }
1240
+ });
1241
+ }
1242
+
1243
+ // A missed warning is the dangerous direction: both of these name an
1244
+ // environment the resolved policy does not scope to.
1245
+ for (const [label, prompt, conditions, word] of [
1246
+ ['an underscore-separated name', 'block schema drops on prod_db', [{ field: 'command', value: 'drop table' }], 'prod'],
1247
+ [
1248
+ 'a blank environment condition no other field can vouch for',
1249
+ 'block schema drops in production',
1250
+ [{ field: 'environment', value: '' }, { field: 'command', value: '*prod*' }],
1251
+ 'production',
1252
+ ],
1253
+ ]) {
1254
+ test(`environment: ${label} warns`, async () => {
1255
+ const h = buildHarness({
1256
+ assistResponse: {
1257
+ success: true,
1258
+ form_updates: {
1259
+ command_family: 'database_write',
1260
+ selected_field: conditions[0].field, field_value: conditions[0].value,
1261
+ conditions,
1262
+ action: 'block', name: 'Block it',
1263
+ },
1264
+ explanation: 'ok',
1265
+ },
1266
+ });
1267
+ try {
1268
+ await runCreate(['--prompt', prompt, '--custom-message', 'No.', '--yes']);
1269
+ assert.deepEqual(h.captured.warn, [
1270
+ `Your prompt mentions \`${word}\`, but this database_write policy has no environment condition, so it applies in all environments.`,
1271
+ ]);
1272
+ } finally {
1273
+ h.restoreLog();
1274
+ }
1275
+ });
1276
+ }
1277
+
1278
+ test('environment: a blank environment condition counts as none; warns', async () => {
1279
+ const h = buildHarness({
1280
+ assistResponse: {
1281
+ success: true,
1282
+ form_updates: {
1283
+ command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
1284
+ conditions: [
1285
+ { field: 'provider', value: '*terraform*' },
1286
+ { field: 'environment', value: ' ' },
1287
+ ],
1288
+ action: 'audit', name: 'Audit terraform destroy',
1289
+ },
1290
+ explanation: 'ok',
1291
+ },
1292
+ });
1293
+ try {
1294
+ await runCreate(['--prompt', 'audit terraform destroy in production', '--yes']);
1295
+ assert.equal(process.exitCode, 0);
1296
+ assert.deepEqual(h.captured.warn, [
1297
+ 'Your prompt mentions `production`, but this cloud_destroy policy has no environment condition, so it applies in all environments.',
1298
+ ]);
1299
+ } finally {
1300
+ h.restoreLog();
1301
+ }
1302
+ });
1303
+
1304
+ // Condition values are matched by whole token, so a value that merely contains
1305
+ // the word ('product-catalog', 'device') does not count as applying it.
1306
+ test('environment (interactive): "prod" is not applied by a *product-catalog* path; warns and Enter aborts', async () => {
1307
+ const h = buildHarness({
1308
+ assistResponse: {
1309
+ success: true,
1310
+ form_updates: {
1311
+ command_family: 'write_file', selected_field: 'path', field_value: '*product-catalog*',
1312
+ action: 'block', name: 'Block product-catalog writes',
1313
+ },
1314
+ explanation: 'ok',
1315
+ },
1316
+ });
1317
+ const rl = stubReadline('');
1318
+ try {
1319
+ await runCreate(['--prompt', 'block filesystem writes to product-catalog in prod', '--custom-message', 'No.']);
1320
+ assert.equal(process.exitCode, 0);
1321
+ assert.deepEqual(h.captured.warn, [
1322
+ 'Your prompt mentions `prod`, but this write_file policy has no environment condition, so it applies in all environments.',
1323
+ 'Aborted.',
1324
+ ]);
1325
+ assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
1326
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
1327
+ } finally {
1328
+ rl.restore();
1329
+ h.restoreLog();
1330
+ }
1331
+ });
1332
+
1333
+ test('environment: "dev" is not applied by a *device* path; warns', async () => {
1334
+ const h = buildHarness({
1335
+ assistResponse: {
1336
+ success: true,
1337
+ form_updates: {
1338
+ command_family: 'write_file', selected_field: 'path', field_value: '*device*',
1339
+ action: 'audit', name: 'Audit device writes',
1340
+ },
1341
+ explanation: 'ok',
1342
+ },
1343
+ });
1344
+ try {
1345
+ await runCreate(['--prompt', 'audit writes to device files in dev', '--yes']);
1346
+ assert.equal(process.exitCode, 0);
1347
+ assert.deepEqual(h.captured.warn, [
1348
+ 'Your prompt mentions `dev`, but this write_file policy has no environment condition, so it applies in all environments.',
1349
+ ]);
1350
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
1351
+ } finally {
1352
+ h.restoreLog();
1353
+ }
1354
+ });
1355
+
1356
+ test('environment: "prod" applied by a custom make release-prod pattern does not warn', async () => {
1357
+ const h = buildHarness({
1358
+ assistResponse: {
1359
+ success: true,
1360
+ form_updates: {
1361
+ command_family: 'custom', selected_field: 'command', field_value: 'make release-prod',
1362
+ action: 'audit', name: 'Audit prod releases',
1363
+ },
1364
+ explanation: 'ok',
1365
+ },
1366
+ });
1367
+ try {
1368
+ await runCreate(['--prompt', 'audit make release-prod', '--yes']);
1369
+ assert.equal(process.exitCode, 0);
1370
+ assert.deepEqual(h.captured.warn, []);
1371
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
1372
+ } finally {
1373
+ h.restoreLog();
1374
+ }
1375
+ });
1376
+
1377
+ test('environment: an environment word another condition matches on (namespace *prod*) does not warn', async () => {
1378
+ const h = buildHarness({
1379
+ assistResponse: {
1380
+ success: true,
1381
+ form_updates: {
1382
+ command_family: 'container', selected_field: 'operation', field_value: 'exec',
1383
+ conditions: [
1384
+ { field: 'operation', value: 'exec' },
1385
+ { field: 'namespace', value: '*prod*' },
1386
+ ],
1387
+ action: 'audit', name: 'Audit kubectl exec in prod namespaces',
1388
+ },
1389
+ explanation: 'ok',
1390
+ },
1391
+ });
1392
+ try {
1393
+ await runCreate(['--prompt', 'audit kubectl exec in the prod namespace', '--yes']);
1394
+ assert.equal(process.exitCode, 0);
1395
+ assert.deepEqual(h.captured.warn, []);
1396
+ const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
1397
+ assert.ok(create, 'create POST should fire');
1398
+ assert.deepEqual(create.body.config, { operation: 'exec', namespace: '*prod*' });
1399
+ } finally {
1400
+ h.restoreLog();
1401
+ }
1402
+ });
1403
+
1404
+ for (const [word, namespace] of [['production', '*prod*'], ['production', 'prod-*'], ['staging', '*stg*'], ['staging', '*stage*']]) {
1405
+ test(`environment: "${word}" with a namespace alias of the same environment (${namespace}) does not warn`, async () => {
1406
+ const h = buildHarness({
1407
+ assistResponse: {
1408
+ success: true,
1409
+ form_updates: {
1410
+ command_family: 'container', selected_field: 'operation', field_value: 'exec',
1411
+ conditions: [
1412
+ { field: 'operation', value: 'exec' },
1413
+ { field: 'namespace', value: namespace },
1414
+ ],
1415
+ action: 'audit', name: `Audit kubectl exec in ${word} namespaces`,
1416
+ },
1417
+ explanation: 'ok',
1418
+ },
1419
+ });
1420
+ try {
1421
+ await runCreate(['--prompt', `audit kubectl exec in ${word} namespaces`, '--yes']);
1422
+ assert.equal(process.exitCode, 0);
1423
+ assert.deepEqual(h.captured.warn, []);
1424
+ const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
1425
+ assert.ok(create, 'create POST should fire');
1426
+ assert.deepEqual(create.body.config, { operation: 'exec', namespace });
1427
+ } finally {
1428
+ h.restoreLog();
1429
+ }
1430
+ });
1431
+ }
1432
+
1433
+ // The condition-value side of this is covered above; this pins the prompt scan.
1434
+ test('environment: "product-catalog" in the prompt is not the word prod; does not warn', async () => {
1435
+ const h = buildHarness({
1436
+ assistResponse: {
1437
+ success: true,
1438
+ form_updates: {
1439
+ command_family: 'filesystem', selected_field: 'command', field_value: '*product-catalog*',
1440
+ action: 'audit', name: 'Audit product-catalog writes',
1441
+ },
1442
+ explanation: 'ok',
1443
+ },
1444
+ });
1445
+ try {
1446
+ await runCreate(['--prompt', 'audit product-catalog writes', '--yes']);
1447
+ assert.equal(process.exitCode, 0);
1448
+ assert.deepEqual(h.captured.warn, []);
1449
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
1450
+ } finally {
1451
+ h.restoreLog();
1452
+ }
1453
+ });
1454
+
1455
+ // Environment words that are also everyday English (test, live, stage, local,
1456
+ // ...) count only in an environment context; the others count anywhere.
1457
+ function auditPushReply(conditions) {
1458
+ return {
1459
+ success: true,
1460
+ form_updates: {
1461
+ command_family: 'git_action', selected_field: 'operation', field_value: 'push',
1462
+ conditions, action: 'audit', name: 'Audit git push',
1463
+ },
1464
+ explanation: 'ok',
1465
+ };
1466
+ }
1467
+
1468
+ for (const [prompt, word] of [
1469
+ ['audit git push to mainnet', 'mainnet'],
1470
+ ['audit git push to the stg branch', 'stg'],
1471
+ ['audit git push in the test environment', 'test'],
1472
+ ['audit git push to live', 'live'],
1473
+ ['audit git push in the local cluster', 'local'],
1474
+ ]) {
1475
+ test(`environment: "${prompt}" warns about ${word}`, async () => {
1476
+ const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
1477
+ try {
1478
+ await runCreate(['--prompt', prompt, '--yes']);
1479
+ assert.equal(process.exitCode, 0);
1480
+ assert.deepEqual(h.captured.warn, [
1481
+ `Your prompt mentions \`${word}\`, but this git_action policy has no environment condition, so it applies in all environments.`,
1482
+ ]);
1483
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
1484
+ } finally {
1485
+ h.restoreLog();
1486
+ }
1487
+ });
1488
+ }
1489
+
1490
+ for (const prompt of ['audit npm test runs', 'audit git stage files', 'audit live reload', 'audit scripts that run tests locally']) {
1491
+ test(`environment: "${prompt}" does not warn (everyday word, no environment context)`, async () => {
1492
+ const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
1493
+ try {
1494
+ await runCreate(['--prompt', prompt, '--yes']);
1495
+ assert.equal(process.exitCode, 0);
1496
+ assert.deepEqual(h.captured.warn, []);
1497
+ } finally {
1498
+ h.restoreLog();
1499
+ }
1500
+ });
1501
+ }
1502
+
1503
+ for (const branch of ['*tst*', 'test-*']) {
1504
+ test(`environment: "in the test environment" applied by branch ${branch} does not warn`, async () => {
1505
+ const h = buildHarness({
1506
+ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }, { field: 'branch', value: branch }]),
1507
+ });
1508
+ try {
1509
+ await runCreate(['--prompt', 'audit git push in the test environment', '--yes']);
1510
+ assert.equal(process.exitCode, 0);
1511
+ assert.deepEqual(h.captured.warn, []);
1512
+ } finally {
1513
+ h.restoreLog();
1514
+ }
1515
+ });
1516
+ }
1517
+
1518
+ test('environment (interactive): "in the test environment" warns and Enter aborts', async () => {
1519
+ const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
1520
+ const rl = stubReadline('');
1521
+ try {
1522
+ await runCreate(['--prompt', 'audit git push in the test environment']);
1523
+ assert.equal(process.exitCode, 0);
1524
+ assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
1525
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
1526
+ } finally {
1527
+ rl.restore();
1528
+ h.restoreLog();
1529
+ }
1530
+ });
1531
+
787
1532
  // --- Preview rendering (Acceptance #6 and #7) ---------------------------------
788
1533
 
789
1534
  test('--yes prints the preview AND skips confirmation AND issues create POST', async () => {
@@ -807,6 +1552,7 @@ test('--yes prints the preview AND skips confirmation AND issues create POST', a
807
1552
  assert.ok(out.includes('Audit pushes'), `name row missing: ${out}`);
808
1553
  assert.ok(out.includes('TERMINAL_COMMAND'), `type row missing: ${out}`);
809
1554
  assert.ok(out.includes('git push*'), `pattern row missing: ${out}`);
1555
+ assert.ok(!out.includes('all must match'), `single condition needs no AND line: ${out}`);
810
1556
  assert.ok(out.includes('AUDIT'), `action row missing: ${out}`);
811
1557
  assert.ok(out.includes('AI assist explanation'), `explanation line missing: ${out}`);
812
1558
  assert.ok(out.includes('audit git activity'), `explanation body missing: ${out}`);
@@ -836,13 +1582,7 @@ test('without --yes: preview prints; "n" declines; no create POST fires', async
836
1582
  explanation: 'ok',
837
1583
  },
838
1584
  });
839
- // Stub readline.createInterface — the lib's confirmCreate uses it.
840
- const readline = require('readline');
841
- const origCreate = readline.createInterface;
842
- readline.createInterface = () => ({
843
- question(_q, cb) { cb('n'); },
844
- close() {},
845
- });
1585
+ const rl = stubReadline('n');
846
1586
  try {
847
1587
  await runCreate(['--prompt', 'audit git pushes']);
848
1588
  // Preview header (stdout) must have printed BEFORE the prompt. And the
@@ -855,7 +1595,7 @@ test('without --yes: preview prints; "n" declines; no create POST fires', async
855
1595
  'no create POST should fire when user declines'
856
1596
  );
857
1597
  } finally {
858
- readline.createInterface = origCreate;
1598
+ rl.restore();
859
1599
  h.restoreLog();
860
1600
  }
861
1601
  });