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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.21",
3
+ "version": "1.16.23",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
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(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
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(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
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(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
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
- .replace(/[^\x20-\x7e\n\t]/g, '');
101
+ // A stripped run becomes a space: a non-breaking space (routine in pasted
102
+ // text) would otherwise glue the words it separated into one.
103
+ .replace(/[^\x20-\x7e\n\t]+/g, ' ');
54
104
  const sanitizedPrompt = collapsed.trim();
55
105
  if (sanitizedPrompt.length > MAX_PROMPT_LEN) {
56
106
  const e = new Error(`Input is too long (max ${MAX_PROMPT_LEN} characters).`);
@@ -59,12 +109,28 @@ function validatePromptPreflight(opts) {
59
109
  }
60
110
  const warnings = [];
61
111
  const lower = sanitizedPrompt.toLowerCase();
62
- for (const token of OUT_OF_SCOPE_KEYWORDS) {
112
+ for (const token of keywords) {
63
113
  if (lower.includes(token)) warnings.push(token);
64
114
  }
65
115
  return { sanitizedPrompt, warnings };
66
116
  }
67
117
 
118
+ function outOfScopeWarning(word, scopes) {
119
+ return `Your prompt mentions \`${word}\`. Tool policies cannot scope by ${scopes} clauses. The endpoint will ignore those parts.`;
120
+ }
121
+
122
+ // `conditions` is the full AND list; selected_field / field_value only mirror
123
+ // conditions[0] for single-condition consumers.
124
+ function configFromAssist(f) {
125
+ const conditions = Array.isArray(f.conditions) ? f.conditions.filter((c) => c && c.field) : [];
126
+ if (!conditions.length) {
127
+ return f.selected_field ? { [f.selected_field]: String(f.field_value || '').trim() } : {};
128
+ }
129
+ const config = {};
130
+ for (const c of conditions) config[c.field] = String(c.value ?? '').trim();
131
+ return config;
132
+ }
133
+
68
134
  function mergeAiAndFlags(formUpdates, opts) {
69
135
  const f = formUpdates || {};
70
136
  const body = {
@@ -74,9 +140,7 @@ function mergeAiAndFlags(formUpdates, opts) {
74
140
  if (f.description) body.description = f.description;
75
141
  if (f.command_family) body.command_family = f.command_family;
76
142
 
77
- const field = f.selected_field || '';
78
- const value = f.field_value || '';
79
- body.config = field ? { [field]: value } : {};
143
+ body.config = configFromAssist(f);
80
144
 
81
145
  body.action = (f.action ? String(f.action).toUpperCase() : 'AUDIT');
82
146
 
@@ -103,6 +167,63 @@ function mergeAiAndFlags(formUpdates, opts) {
103
167
  return body;
104
168
  }
105
169
 
170
+ // True when a condition value already targets the environment `word`: one of
171
+ // its tokens is the word (branch 'production', 'make release-prod') or an alias
172
+ // of the same label (namespace '*prod*' or 'prod-*' for "production").
173
+ // Whole tokens only, so 'product-catalog' does not target "prod".
174
+ function targetsEnvironment(value, word) {
175
+ const label = normaliseEnvironmentWord(word);
176
+ const v = String(value).toLowerCase();
177
+ // An all-wildcard pattern matches every environment, so it targets this one.
178
+ if (/^\*+$/.test(v.trim())) return true;
179
+ // Tokens split on non-alphanumerics, so an alias holding one (`127.0.0.1`)
180
+ // never survives as a token and is matched on the whole value.
181
+ if (DOTTED_OR_HYPHENATED_ALIASES.some((a) => v.includes(a) && ENVIRONMENT_LABELS[a] === label)) {
182
+ return true;
183
+ }
184
+ // `pre-prod` is pre-production however it is spelled, so it is consumed before
185
+ // tokenising: the `prod` left behind would otherwise read as production.
186
+ let preprod = false;
187
+ const tokens = v.replace(SEPARATED_PREPROD_RE, () => { preprod = true; return ' '; }).split(/[^a-z0-9]+/);
188
+ if (preprod && label === ENVIRONMENT_LABELS.preprod) return true;
189
+ return tokens.some(
190
+ (token) => token === word || (label !== undefined && ENVIRONMENT_LABELS[token] === label)
191
+ );
192
+ }
193
+
194
+ // Only some families (cloud_*, database_*, build_operation) have an
195
+ // `environment` field, so the resolved policy can silently drop or narrow an
196
+ // environment the prompt named. A word some condition already targets was
197
+ // applied, so it does not warn. Returns true when it warned.
198
+ function warnEnvironmentScope(prompt, body) {
199
+ const environment = String(body.config.environment ?? '').trim();
200
+ // An `environment` condition decides scope on its own. Another field holding
201
+ // the word (`service: prod-api`) does not widen it, so it cannot vouch here.
202
+ // Present-but-blank still decides: a blank condition scopes to nothing.
203
+ const values = Object.prototype.hasOwnProperty.call(body.config, 'environment')
204
+ ? [environment]
205
+ : Object.values(body.config);
206
+ const words = new Set([...prompt.matchAll(ENVIRONMENT_WORD_RE)].map((m) => (m[1] || m[2] || m[3]).toLowerCase()));
207
+ let warned = environment ? warnUnrecognisedEnvironment(environment) : false;
208
+ for (const word of words) {
209
+ if (values.some((v) => targetsEnvironment(v, word))) continue;
210
+ const scope = environment
211
+ ? `this policy's environment condition is \`${environment}\`, so it does not apply in ${word}`
212
+ : `this ${body.command_family || 'terminal'} policy has no environment condition, so it applies in all environments`;
213
+ output.warn(`Your prompt mentions \`${word}\`, but ${scope}.`);
214
+ warned = true;
215
+ }
216
+ return warned;
217
+ }
218
+
219
+ function warnUnrecognisedEnvironment(environment) {
220
+ if (String(environment).replace(/\*/g, '').trim().toLowerCase() !== 'qa') return false;
221
+ const labels = [...new Set(Object.values(ENVIRONMENT_LABELS))];
222
+ const aliases = Object.keys(ENVIRONMENT_LABELS).filter((token) => ENVIRONMENT_LABELS[token] !== token);
223
+ output.warn(`\`qa\` is not a recognised environment label (recognised: ${labels.join(', ')}, and their aliases ${aliases.join(', ')}), so this policy may not match.`);
224
+ return true;
225
+ }
226
+
106
227
  function colorAction(action) {
107
228
  const colors = output.colors;
108
229
  if (!action) return '(unspecified)';
@@ -117,10 +238,8 @@ function renderTerminalPreview(body, explanation) {
117
238
  ? `groups: ${body.scope_user_group_ids.join(', ')}`
118
239
  : '(org-wide)';
119
240
  const family = body.command_family || '(unspecified)';
120
- const cfg = body.config || {};
121
- const cfgKeys = Object.keys(cfg);
122
- const fieldKey = cfgKeys[0] || '(unspecified)';
123
- const pattern = cfgKeys.length ? String(cfg[cfgKeys[0]]) : '(unspecified)';
241
+ const cfgEntries = Object.entries(body.config || {});
242
+ const matches = cfgEntries.length ? cfgEntries : [['(unspecified)', '(unspecified)']];
124
243
 
125
244
  // Header goes to stdout (NOT output.info, which writes to stderr) so the
126
245
  // whole preview — header, keyValue rows, explanation — is captured by a
@@ -134,8 +253,11 @@ function renderTerminalPreview(body, explanation) {
134
253
  if (body.description) rows.push(['Description', body.description]);
135
254
  rows.push(['Type', body.policy_type]);
136
255
  rows.push(['Command family', family]);
137
- rows.push(['Field', fieldKey]);
138
- rows.push(['Pattern', pattern]);
256
+ if (matches.length > 1) rows.push(['Conditions', output.colors.dim('all must match (AND)')]);
257
+ for (const [key, value] of matches) {
258
+ rows.push(['Field', key]);
259
+ rows.push(['Pattern', String(value)]);
260
+ }
139
261
  rows.push(['Action', colorAction(body.action)]);
140
262
  if (body.custom_message) rows.push(['Custom message', body.custom_message]);
141
263
  rows.push(['Scope (groups)', scope]);
@@ -204,6 +326,10 @@ function confirmCreate() {
204
326
  return promptYesNo('Create policy? [Y/n] ', true);
205
327
  }
206
328
 
329
+ function confirmCreateAnyway() {
330
+ return promptYesNo('Create policy anyway? [y/N] ', false);
331
+ }
332
+
207
333
  function confirmContinue() {
208
334
  return promptYesNo('Continue anyway? The endpoint will ignore those parts. [y/N] ', false);
209
335
  }
@@ -237,9 +363,9 @@ async function runTerminalPromptCreate(opts) {
237
363
  // care about the throw side-effect; mergeAiAndFlags re-validates and applies.
238
364
  validateActionOverride(opts.action);
239
365
 
240
- const { sanitizedPrompt, warnings } = validatePromptPreflight(opts);
366
+ const { sanitizedPrompt, warnings } = validatePromptPreflight(opts, OUT_OF_SCOPE_KEYWORDS);
241
367
  for (const w of warnings) {
242
- output.warn(`Your prompt mentions \`${w}\`. Tool policies cannot scope by environment / project / time / exception clauses. The endpoint will ignore those parts.`);
368
+ output.warn(outOfScopeWarning(w, 'project / time / exception'));
243
369
  }
244
370
  if (warnings.length && !opts.yes && !opts.json) {
245
371
  const cont = await confirmContinue();
@@ -314,10 +440,11 @@ async function runTerminalPromptCreate(opts) {
314
440
  }
315
441
 
316
442
  if (!opts.json) renderTerminalPreview(body, response.explanation);
443
+ const warned = warnEnvironmentScope(sanitizedPrompt, body);
317
444
 
318
445
  let confirmed = true;
319
446
  if (!opts.yes && !opts.json) {
320
- confirmed = await confirmCreate();
447
+ confirmed = await (warned ? confirmCreateAnyway() : confirmCreate());
321
448
  }
322
449
  return { body, confirmed, explanation: response.explanation };
323
450
  }
@@ -402,9 +529,9 @@ async function runMcpPromptCreate(opts) {
402
529
 
403
530
  validateActionOverride(opts.action);
404
531
 
405
- const { sanitizedPrompt, warnings } = validatePromptPreflight(opts);
532
+ const { sanitizedPrompt, warnings } = validatePromptPreflight(opts, MCP_OUT_OF_SCOPE_KEYWORDS);
406
533
  for (const w of warnings) {
407
- output.warn(`Your prompt mentions \`${w}\`. Tool policies cannot scope by environment / project / time / exception clauses. The endpoint will ignore those parts.`);
534
+ output.warn(outOfScopeWarning(w, 'environment / project / time / exception'));
408
535
  }
409
536
  if (warnings.length && !opts.yes && !opts.json) {
410
537
  const cont = await confirmContinue();
@@ -494,6 +621,7 @@ async function runMcpPromptCreate(opts) {
494
621
  module.exports = {
495
622
  MAX_PROMPT_LEN,
496
623
  OUT_OF_SCOPE_KEYWORDS,
624
+ MCP_OUT_OF_SCOPE_KEYWORDS,
497
625
  validatePromptPreflight,
498
626
  mergeAiAndFlags,
499
627
  renderTerminalPreview,
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: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(pf, 'Codex') },
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
- function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
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 list — every token surfaces a warning.
31
+ // §6.2 step 4: parametrized over the keyword lists — every token surfaces a warning.
31
32
  for (const token of OUT_OF_SCOPE_KEYWORDS) {
32
33
  test(`preflight: out-of-scope keyword "${token}" produces a warning`, () => {
33
- const r = validatePromptPreflight({ prompt: `block X in ${token} mode` });
34
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, OUT_OF_SCOPE_KEYWORDS);
34
35
  assert.ok(r.warnings.includes(token), `expected warning for ${token}, got ${JSON.stringify(r.warnings)}`);
35
36
  });
36
37
  }
37
38
 
39
+ for (const token of MCP_OUT_OF_SCOPE_KEYWORDS) {
40
+ test(`preflight: MCP out-of-scope keyword "${token}" produces a warning`, () => {
41
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, MCP_OUT_OF_SCOPE_KEYWORDS);
42
+ assert.ok(r.warnings.includes(token), `expected warning for ${token}, got ${JSON.stringify(r.warnings)}`);
43
+ });
44
+ }
45
+
46
+ // Terminal policies can scope by environment, so these only warn for MCP.
47
+ for (const token of ['staging', 'production', 'prod', 'dev', 'qa', 'testing']) {
48
+ test(`preflight: environment keyword "${token}" does not warn with the terminal list`, () => {
49
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` }, OUT_OF_SCOPE_KEYWORDS);
50
+ assert.deepEqual(r.warnings, []);
51
+ });
52
+ }
53
+
38
54
  // §6.2 step 5: collapse runs of >=2 newlines to a single newline.
39
55
  test('preflight: collapses three consecutive newlines to one', () => {
40
- const r = validatePromptPreflight({ prompt: 'line1\n\n\nline2' });
56
+ const r = validatePromptPreflight({ prompt: 'line1\n\n\nline2' }, OUT_OF_SCOPE_KEYWORDS);
41
57
  assert.equal(r.sanitizedPrompt, 'line1\nline2');
42
58
  });
43
59
 
44
60
  test('preflight: collapses two consecutive newlines to one', () => {
45
- const r = validatePromptPreflight({ prompt: 'a\n\nb' });
61
+ const r = validatePromptPreflight({ prompt: 'a\n\nb' }, OUT_OF_SCOPE_KEYWORDS);
46
62
  assert.equal(r.sanitizedPrompt, 'a\nb');
47
63
  });
48
64
 
49
65
  test('preflight: a single newline is left alone', () => {
50
- const r = validatePromptPreflight({ prompt: 'a\nb' });
66
+ const r = validatePromptPreflight({ prompt: 'a\nb' }, OUT_OF_SCOPE_KEYWORDS);
51
67
  assert.equal(r.sanitizedPrompt, 'a\nb');
52
68
  });
53
69
 
70
+ // A stripped character must not join the words it separated: pasted text
71
+ // carries non-breaking spaces, and "inproduction" names no environment.
72
+ test('preflight: a non-breaking space becomes a space, not nothing', () => {
73
+ const r = validatePromptPreflight({ prompt: 'block terraform destroy in production' }, OUT_OF_SCOPE_KEYWORDS);
74
+ assert.equal(r.sanitizedPrompt, 'block terraform destroy in production');
75
+ });
76
+
77
+ // A negated environment is an exception clause, which policies cannot express.
78
+ for (const prompt of ['block schema drops in non-prod', 'block schema drops in non production', 'block schema drops in non-production']) {
79
+ test(`preflight: "${prompt}" reports a negated environment as out of scope`, () => {
80
+ const r = validatePromptPreflight({ prompt }, OUT_OF_SCOPE_KEYWORDS);
81
+ assert.ok(r.warnings.some((w) => w.startsWith('non')), `expected a negation warning, got ${JSON.stringify(r.warnings)}`);
82
+ });
83
+ }
84
+
85
+ test('preflight: "non-interactive" is not a negated environment', () => {
86
+ const r = validatePromptPreflight({ prompt: 'audit non-interactive scripts' }, OUT_OF_SCOPE_KEYWORDS);
87
+ assert.deepEqual(r.warnings, []);
88
+ });
89
+
54
90
  // §6.2: warning is informational only; under --yes the caller logs it and
55
91
  // continues. The helper itself never throws for warnings — it just reports.
56
92
  test('preflight: warnings do not throw', () => {
57
- // Use a keyword that is NOT a substring of "production" (the keyword set
58
- // contains "prod" which would also match): pick a non-overlapping one.
59
- const r = validatePromptPreflight({ prompt: 'block weekend pushes' });
93
+ const r = validatePromptPreflight({ prompt: 'block weekend pushes' }, OUT_OF_SCOPE_KEYWORDS);
60
94
  assert.ok(r.warnings.includes('weekend'));
61
95
  // No throw — the function returned and we got here.
62
96
  });