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 +1 -1
- package/src/lib/policy-ai-assist.js +147 -19
- package/test/policy-ai-assist-preflight.test.js +45 -11
- package/test/policy-ai-assist.test.js +752 -12
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
121
|
-
const
|
|
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(['
|
|
138
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 + "
|
|
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
|
|
893
|
+
await runCreate(['--prompt', 'block git pushes on weekends', '--yes']);
|
|
774
894
|
assert.ok(
|
|
775
|
-
h.captured.warn.some((m) => m.includes('`
|
|
776
|
-
`expected
|
|
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
|
-
|
|
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
|
-
|
|
1598
|
+
rl.restore();
|
|
859
1599
|
h.restoreLog();
|
|
860
1600
|
}
|
|
861
1601
|
});
|