unbound-cli 1.16.21 → 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/commands/doctor.js +7 -5
- package/src/lib/policy-ai-assist.js +147 -19
- package/src/toolHealth.js +32 -4
- package/test/codex-managed-hooks-only.test.js +58 -0
- package/test/policy-ai-assist-preflight.test.js +45 -11
- package/test/policy-ai-assist.test.js +752 -12
- package/test/tool-health-mdm-paths.test.js +1 -1
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -130,7 +130,7 @@ Examples:
|
|
|
130
130
|
const reasons = [];
|
|
131
131
|
if (t.conflict) reasons.push('two modes installed');
|
|
132
132
|
for (const c of t.checks) if (!c.ok) reasons.push(c.summary || c.name);
|
|
133
|
-
s = C.red(`✗ Tampered — ${reasons.join(', ')}`);
|
|
133
|
+
s = C.red(`✗ ${t.notEnforced ? 'Not enforced' : 'Tampered'} — ${reasons.join(', ')}`);
|
|
134
134
|
} else s = C.dim('○ Not set up');
|
|
135
135
|
console.log(` ${labelOf(t).padEnd(W)} ${s}`);
|
|
136
136
|
}
|
|
@@ -153,6 +153,8 @@ Examples:
|
|
|
153
153
|
const userNames = userTampered.map((t) => t.label).join(', ');
|
|
154
154
|
const mdmNames = mdmTampered.map((t) => t.label).join(', ');
|
|
155
155
|
const allFix = tampered.map((t) => t.key);
|
|
156
|
+
// A codex policy can live only in the user's cache, which a sudo re-detection may not see.
|
|
157
|
+
const mdmCmd = elevated(mdmTampered.some((t) => t.notEnforced) ? `unbound setup ${mdmFix.join(' ')}` : 'unbound doctor --fix');
|
|
156
158
|
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
157
159
|
|
|
158
160
|
if (opts.fix) {
|
|
@@ -176,7 +178,7 @@ Examples:
|
|
|
176
178
|
// tools only, and point at sudo for whatever's org-managed.
|
|
177
179
|
if (!root && !userFix.length) {
|
|
178
180
|
console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
179
|
-
console.log(` Run ${C.bold(
|
|
181
|
+
console.log(` Run ${C.bold(mdmCmd)} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
180
182
|
console.log('');
|
|
181
183
|
process.exitCode = 1;
|
|
182
184
|
return;
|
|
@@ -186,7 +188,7 @@ Examples:
|
|
|
186
188
|
output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
|
|
187
189
|
console.log('');
|
|
188
190
|
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
|
|
189
|
-
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(
|
|
191
|
+
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(mdmCmd)} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
190
192
|
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0) || ((configMismatch || blankConfigDir) ? 1 : 0);
|
|
191
193
|
return;
|
|
192
194
|
}
|
|
@@ -213,11 +215,11 @@ Examples:
|
|
|
213
215
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
214
216
|
} else if (!userFix.length) {
|
|
215
217
|
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
216
|
-
console.log(` Run ${C.bold(
|
|
218
|
+
console.log(` Run ${C.bold(mdmCmd)} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
217
219
|
} else {
|
|
218
220
|
// Both: a user without sudo can still fix their own tools; sudo fixes everything.
|
|
219
221
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
220
|
-
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(
|
|
222
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(mdmCmd)} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
if (key.state === 'invalid') {
|
|
@@ -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,
|
package/src/toolHealth.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const os = require('os');
|
|
18
18
|
const path = require('path');
|
|
19
|
-
const { spawnSync } = require('child_process');
|
|
19
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
20
20
|
const TOML = require('@iarna/toml');
|
|
21
21
|
const claudePaths = require('./claudePaths');
|
|
22
22
|
|
|
@@ -238,7 +238,7 @@ function managedDir(family, platform = process.platform, env = process.env) {
|
|
|
238
238
|
const dirs = {
|
|
239
239
|
cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(pd, 'Cursor') },
|
|
240
240
|
'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(pf, 'ClaudeCode') },
|
|
241
|
-
codex: { darwin:
|
|
241
|
+
codex: { darwin: '/etc/codex', linux: '/etc/codex', win32: path.join(pf, 'Codex') },
|
|
242
242
|
augment: { darwin: '/etc/augment', linux: '/etc/augment', win32: path.join(pd, 'Augment') },
|
|
243
243
|
};
|
|
244
244
|
if (!dirs[family]) return null;
|
|
@@ -371,7 +371,7 @@ function buildVariants(gatewayUrl, binaryPath) {
|
|
|
371
371
|
const features = cfg.features;
|
|
372
372
|
if (!features || typeof features !== 'object') return false;
|
|
373
373
|
return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
|
|
374
|
-
} }, { short: 'codex hooks not enabled' }),
|
|
374
|
+
} }, { short: 'codex hooks not enabled', kind: 'aux' }), // aux: an org install leaves it set with no user hook
|
|
375
375
|
scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
|
|
376
376
|
envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
|
|
377
377
|
],
|
|
@@ -436,6 +436,23 @@ function detectVariant(variant) {
|
|
|
436
436
|
return { key: variant.key, label: variant.label, family: variant.family, mode: variant.mode, status: statusOf(checks), checks };
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
// Where codex's requirements set allow_managed_hooks_only (codex then skips user-level hooks), or null.
|
|
440
|
+
// Reads the host's real policy files, like mdmDetect reads its managed dirs; tests pass _codexManagedOnly.
|
|
441
|
+
function codexManagedHooksOnly() {
|
|
442
|
+
const on = (text) => /^[ \t]*["']?allow_managed_hooks_only["']?[ \t]*=[ \t]*true\b/m.test((text || '').replace(/^\uFEFF/, ''));
|
|
443
|
+
const strings = (v) => (typeof v === 'string' ? [v] : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : []);
|
|
444
|
+
const system = process.platform === 'win32' ? path.join(process.env.ProgramData || 'C:\\ProgramData', 'OpenAI', 'Codex', 'requirements.toml') : '/etc/codex/requirements.toml';
|
|
445
|
+
if (on(readText(system))) return system;
|
|
446
|
+
// The cloud cache is JSON; its TOML lives in string values.
|
|
447
|
+
const cache = path.join(process.env.CODEX_HOME || path.join(HOME, '.codex'), 'cloud-config-bundle-cache.json');
|
|
448
|
+
if (strings(readJson(cache)).some(on)) return cache;
|
|
449
|
+
const plist = '/Library/Managed Preferences/com.openai.codex.plist';
|
|
450
|
+
try {
|
|
451
|
+
const b64 = execFileSync('/usr/bin/plutil', ['-extract', 'requirements_toml_base64', 'raw', '-o', '-', plist], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
452
|
+
return on(Buffer.from(b64, 'base64').toString()) ? plist : null;
|
|
453
|
+
} catch { return null; }
|
|
454
|
+
}
|
|
455
|
+
|
|
439
456
|
// Collapse the per-(tool,mode) variants to one entry per product family. Picks
|
|
440
457
|
// the installed/tampered mode if any; otherwise reports managed-by-mdm or
|
|
441
458
|
// not-installed. This is what both `doctor` and `status` render.
|
|
@@ -443,7 +460,8 @@ function detectVariant(variant) {
|
|
|
443
460
|
// org-managed scenarios can be exercised without writing under /Library or /etc.
|
|
444
461
|
// `_binaryPath` (test-only) overrides the system hook-binary path so binary-mode
|
|
445
462
|
// scenarios can be exercised without writing under /opt.
|
|
446
|
-
|
|
463
|
+
// `_codexManagedOnly` (test-only) replaces the codexManagedHooksOnly() result.
|
|
464
|
+
function detectTools({ gatewayUrl, _mdmDirs, _binaryPath, _codexManagedOnly } = {}) {
|
|
447
465
|
const variants = buildVariants(gatewayUrl, _binaryPath).map(detectVariant);
|
|
448
466
|
const families = [];
|
|
449
467
|
const seen = new Set();
|
|
@@ -472,6 +490,16 @@ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
|
|
|
472
490
|
}
|
|
473
491
|
}
|
|
474
492
|
}
|
|
493
|
+
// A working org hook in codex's system folder runs regardless, so only a user-level install is at risk.
|
|
494
|
+
// Gateway mode proxies traffic and is unaffected; a dual-mode install already reports 'two modes installed'.
|
|
495
|
+
const codex = families.find((t) => t.key === 'codex-subscription' && t.status !== 'not-installed');
|
|
496
|
+
const atRisk = codex && mdmDetect('codex', _mdmDirs && _mdmDirs.codex, _binaryPath).status !== 'healthy';
|
|
497
|
+
const managedOnly = atRisk && (_codexManagedOnly !== undefined ? _codexManagedOnly : codexManagedHooksOnly());
|
|
498
|
+
if (managedOnly) {
|
|
499
|
+
codex.checks.push({ name: 'Managed hooks only', ok: false, kind: 'structural', summary: 'Codex only runs managed hooks',
|
|
500
|
+
detail: `allow_managed_hooks_only is set in ${managedOnly}` });
|
|
501
|
+
Object.assign(codex, { status: 'tampered', notEnforced: true, scope: 'mdm' });
|
|
502
|
+
}
|
|
475
503
|
return families;
|
|
476
504
|
}
|
|
477
505
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
function detectCodex({ userHook = true, mdmHook = false, managedOnly = null, cache, real = false } = {}) {
|
|
8
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-codex-'));
|
|
9
|
+
const write = (p, text) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, text); };
|
|
10
|
+
const saved = { HOME: process.env.HOME, KEY: process.env.UNBOUND_CODEX_API_KEY, CODEX_HOME: process.env.CODEX_HOME };
|
|
11
|
+
try {
|
|
12
|
+
process.env.HOME = home;
|
|
13
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
14
|
+
process.env.CODEX_HOME = path.join(home, '.codex');
|
|
15
|
+
if (cache) write(path.join(home, '.codex', 'cloud-config-bundle-cache.json'), JSON.stringify(cache));
|
|
16
|
+
write(path.join(home, '.codex', 'config.toml'), '[features]\nhooks = true\n');
|
|
17
|
+
if (userHook) {
|
|
18
|
+
write(path.join(home, '.codex', 'hooks.json'), JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'unbound.py' }] }] } }));
|
|
19
|
+
write(path.join(home, '.codex', 'hooks', 'unbound.py'), '');
|
|
20
|
+
}
|
|
21
|
+
const mdm = path.join(home, 'etc-codex');
|
|
22
|
+
if (mdmHook) {
|
|
23
|
+
write(path.join(mdm, 'hooks.json'), JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'unbound.py' }] }] } }));
|
|
24
|
+
write(path.join(mdm, 'hooks', 'unbound.py'), '');
|
|
25
|
+
}
|
|
26
|
+
delete require.cache[require.resolve('../src/toolHealth')];
|
|
27
|
+
delete require.cache[require.resolve('../src/claudePaths')];
|
|
28
|
+
const { detectTools } = require('../src/toolHealth');
|
|
29
|
+
return detectTools({ _mdmDirs: { codex: mdm }, _codexManagedOnly: real ? undefined : managedOnly }).find((t) => t.family === 'codex');
|
|
30
|
+
} finally {
|
|
31
|
+
process.env.HOME = saved.HOME;
|
|
32
|
+
if (saved.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = saved.CODEX_HOME;
|
|
33
|
+
if (saved.KEY === undefined) delete process.env.UNBOUND_CODEX_API_KEY; else process.env.UNBOUND_CODEX_API_KEY = saved.KEY;
|
|
34
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('a user-level codex hook under allow_managed_hooks_only is not enforced and routed org-wide', () => {
|
|
39
|
+
const t = detectCodex({ managedOnly: '/etc/codex/requirements.toml' });
|
|
40
|
+
assert.equal(t.status, 'tampered');
|
|
41
|
+
assert.equal(t.notEnforced, true);
|
|
42
|
+
assert.equal(t.scope, 'mdm');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an org install that left the feature flag behind reads as managed, not tampered', () => {
|
|
46
|
+
const t = detectCodex({ userHook: false, mdmHook: true, managedOnly: '/etc/codex/requirements.toml' });
|
|
47
|
+
assert.equal(t.status, 'managed-by-mdm');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the setting on the first line of the cloud policy cache is detected', () => {
|
|
51
|
+
const t = detectCodex({ real: true, cache: { toml: 'allow_managed_hooks_only = true\n' } });
|
|
52
|
+
assert.equal(t.notEnforced, true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a working org hook means the user install is not flagged', () => {
|
|
56
|
+
const t = detectCodex({ mdmHook: true, managedOnly: '/etc/codex/requirements.toml' });
|
|
57
|
+
assert.ok(!t.notEnforced);
|
|
58
|
+
});
|
|
@@ -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
|
});
|