@nerviq/cli 1.8.8 → 1.8.9

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/bin/cli.js CHANGED
@@ -308,6 +308,7 @@ function printScanDetail(summary, options) {
308
308
 
309
309
  // Show per-category breakdown if result is available
310
310
  if (item.result && item.result.results) {
311
+ const STACK_LANGUAGES = new Set(['python', 'go', 'rust', 'java', 'ruby', 'dotnet', 'php', 'flutter', 'swift', 'kotlin']);
311
312
  const categories = {};
312
313
  for (const r of item.result.results) {
313
314
  const cat = r.category || 'other';
@@ -315,7 +316,9 @@ function printScanDetail(summary, options) {
315
316
  categories[cat].total++;
316
317
  if (r.passed) categories[cat].passed++;
317
318
  }
318
- const catEntries = Object.entries(categories).sort((a, b) => (a[1].passed / a[1].total) - (b[1].passed / b[1].total));
319
+ const catEntries = Object.entries(categories)
320
+ .filter(([cat, v]) => v.passed > 0 || !STACK_LANGUAGES.has(cat))
321
+ .sort((a, b) => (a[1].passed / a[1].total) - (b[1].passed / b[1].total));
319
322
  const catLine = catEntries.map(([cat, v]) => `${cat}: ${v.passed}/${v.total}`).join(' ');
320
323
  console.log(` \x1b[2m${catLine}\x1b[0m`);
321
324
  }
@@ -1402,7 +1405,21 @@ async function main() {
1402
1405
  console.error('\n Error: Profile name required. Usage: nerviq profile load <name>\n');
1403
1406
  process.exit(1);
1404
1407
  }
1405
- const profile = loadProfile(options.dir, profileArg);
1408
+ let profile;
1409
+ try {
1410
+ profile = loadProfile(options.dir, profileArg);
1411
+ } catch {
1412
+ // Not found as a user-saved profile — try built-in governance profiles
1413
+ const { getPermissionProfile } = require('../src/governance');
1414
+ const builtIn = getPermissionProfile(profileArg);
1415
+ if (builtIn && builtIn.key === profileArg) {
1416
+ profile = { name: builtIn.label, platforms: ['claude'], threshold: builtIn.threshold || 0, ...builtIn };
1417
+ }
1418
+ }
1419
+ if (!profile) {
1420
+ console.error(`\n Error: Profile '${profileArg}' not found. Run 'nerviq profile list' to see available profiles.\n`);
1421
+ process.exit(1);
1422
+ }
1406
1423
 
1407
1424
  // Apply profile settings to .claude/settings.json
1408
1425
  const fs = require('fs');
@@ -1458,8 +1475,35 @@ async function main() {
1458
1475
  process.exit(1);
1459
1476
  }
1460
1477
  } else if (normalizedCommand === 'synergy-report') {
1461
- // Placeholder synergy report is referenced but may not be implemented yet
1462
- console.log('\n Synergy report: coming soon.\n');
1478
+ const { formatSynergyReport } = require('../src/synergy/report');
1479
+ const { detectActivePlatforms: detectSynergyPlatforms } = require('../src/harmony/canon');
1480
+ const presentPlatforms = detectSynergyPlatforms(options.dir).map(p => p.platform);
1481
+ if (presentPlatforms.length === 0) {
1482
+ console.log('\n No platform configurations detected.');
1483
+ console.log(' Run "nerviq harmony-audit" first, or "nerviq setup" to bootstrap a platform.\n');
1484
+ process.exit(0);
1485
+ }
1486
+ const platformAudits = {};
1487
+ const activePlatforms = [];
1488
+ for (const plat of presentPlatforms) {
1489
+ try {
1490
+ const result = await audit({ dir: options.dir, silent: true, platform: plat });
1491
+ if (result && typeof result.score === 'number') {
1492
+ platformAudits[plat] = result;
1493
+ activePlatforms.push(plat);
1494
+ }
1495
+ } catch (_e) { /* platform not available */ }
1496
+ }
1497
+ if (activePlatforms.length === 0) {
1498
+ console.log('\n No auditable platforms found. Run "nerviq harmony-audit" first.\n');
1499
+ process.exit(0);
1500
+ }
1501
+ const report = formatSynergyReport({ platformAudits, activePlatforms });
1502
+ if (options.json) {
1503
+ console.log(JSON.stringify({ activePlatforms, platformAudits }, null, 2));
1504
+ } else {
1505
+ console.log(report);
1506
+ }
1463
1507
  process.exit(0);
1464
1508
  } else if (normalizedCommand === 'doctor') {
1465
1509
  const { runDoctor } = require('../src/doctor');
@@ -1888,6 +1932,16 @@ async function main() {
1888
1932
  process.exit(0);
1889
1933
  }
1890
1934
  const result = await audit(options);
1935
+ if (options.out) {
1936
+ const fs = require('fs');
1937
+ const path = require('path');
1938
+ const outPath = path.resolve(options.out);
1939
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1940
+ fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8');
1941
+ if (!options.json) {
1942
+ console.log(`\n Audit report written to ${options.out}\n`);
1943
+ }
1944
+ }
1891
1945
  if (options.webhookUrl) {
1892
1946
  try {
1893
1947
  const { sendWebhook, formatSlackMessage } = require('../src/integrations');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.8.8",
3
+ "version": "1.8.9",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 checks (8 platforms × ~300 governance rules), 10 languages, 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/benchmark.js CHANGED
@@ -321,9 +321,13 @@ function printBenchmark(report, options = {}) {
321
321
  console.log(' ═══════════════════════════════════════');
322
322
  console.log(' Runs in an isolated temp copy. Your current repo is not modified.');
323
323
  console.log('');
324
- console.log(` Before: ${report.before.score}/100 (organic ${report.before.organicScore}/100)`);
325
- console.log(` After: ${report.after.score}/100 (organic ${report.after.organicScore}/100)`);
326
- console.log(` Delta: score ${report.delta.score >= 0 ? '+' : ''}${report.delta.score}, organic ${report.delta.organicScore >= 0 ? '+' : ''}${report.delta.organicScore}`);
324
+ const orgDeltaSign = report.delta.organicScore >= 0 ? '+' : '';
325
+ const totalDeltaSign = report.delta.score >= 0 ? '+' : '';
326
+ console.log(` Organic improvement: \x1b[1m${orgDeltaSign}${report.delta.organicScore} points\x1b[0m (your actual config quality)`);
327
+ console.log(` Total with nerviq setup: ${totalDeltaSign}${report.delta.score} points`);
328
+ console.log('');
329
+ console.log(` Before: organic ${report.before.organicScore}/100, total ${report.before.score}/100`);
330
+ console.log(` After: organic ${report.after.organicScore}/100, total ${report.after.score}/100`);
327
331
  console.log('');
328
332
  console.log(` ${report.executiveSummary.headline}`);
329
333
  console.log(` Recommendation: ${report.executiveSummary.decisionGuidance}`);
@@ -37,10 +37,14 @@ async function certifyProject(dir) {
37
37
 
38
38
  // Run per-platform audits
39
39
  const platformScores = {};
40
+ const allAuditResults = [];
40
41
  for (const platform of platforms) {
41
42
  try {
42
43
  const result = await audit({ dir: resolvedDir, platform, silent: true });
43
44
  platformScores[platform] = result.score;
45
+ if (Array.isArray(result.results)) {
46
+ allAuditResults.push(...result.results);
47
+ }
44
48
  } catch {
45
49
  platformScores[platform] = 0;
46
50
  }
@@ -55,18 +59,36 @@ async function certifyProject(dir) {
55
59
  harmonyScore = 0;
56
60
  }
57
61
 
58
- // Determine certification level
62
+ // Determine certification level with security gates
59
63
  const scores = Object.values(platformScores);
60
64
  const allAbove70 = scores.length > 0 && scores.every(s => s >= 70);
61
65
  const allAbove50 = scores.length > 0 && scores.every(s => s >= 50);
62
66
  const anyAbove40 = scores.some(s => s >= 40);
63
67
 
68
+ // Security gate helpers — check whether specific audit checks passed
69
+ const checkPassed = (key) => {
70
+ const match = allAuditResults.find(r => r.key === key);
71
+ return match ? match.passed === true : false;
72
+ };
73
+
74
+ const gitIgnoreOk = checkPassed('gitIgnoreEnv');
75
+ const secretsOk = checkPassed('secretsProtection');
76
+ const criticalAntiPatterns = allAuditResults.filter(
77
+ r => r.passed === false && r.impact === 'critical'
78
+ );
79
+ const noCriticalAntiPatterns = criticalAntiPatterns.length === 0;
80
+
81
+ // Bronze gate: score >= 40 AND basic security (gitignore + secrets protection)
82
+ const bronzeSecurityGate = gitIgnoreOk && secretsOk;
83
+ // Silver gate: Bronze requirements AND no critical anti-patterns
84
+ const silverSecurityGate = bronzeSecurityGate && noCriticalAntiPatterns;
85
+
64
86
  let level;
65
- if (harmonyScore >= 80 && allAbove70) {
87
+ if (harmonyScore >= 80 && allAbove70 && silverSecurityGate) {
66
88
  level = LEVELS.GOLD;
67
- } else if (harmonyScore >= 60 && allAbove50) {
89
+ } else if (harmonyScore >= 60 && allAbove50 && silverSecurityGate) {
68
90
  level = LEVELS.SILVER;
69
- } else if (anyAbove40) {
91
+ } else if (anyAbove40 && bronzeSecurityGate) {
70
92
  level = LEVELS.BRONZE;
71
93
  } else {
72
94
  level = LEVELS.NONE;
@@ -80,6 +102,12 @@ async function certifyProject(dir) {
80
102
  platformScores,
81
103
  platforms,
82
104
  badge,
105
+ securityGates: {
106
+ gitIgnoreEnv: gitIgnoreOk,
107
+ secretsProtection: secretsOk,
108
+ noCriticalAntiPatterns,
109
+ criticalAntiPatternCount: criticalAntiPatterns.length,
110
+ },
83
111
  };
84
112
  }
85
113
 
package/src/governance.js CHANGED
@@ -55,7 +55,7 @@ const HOOK_REGISTRY = [
55
55
  key: 'protect-secrets',
56
56
  file: '.claude/hooks/protect-secrets.sh',
57
57
  triggerPoint: 'PreToolUse',
58
- matcher: 'Read|Write|Edit',
58
+ matcher: 'Read|Write|Edit|Bash',
59
59
  purpose: 'Blocks direct access to secret or credential files before a tool runs.',
60
60
  filesTouched: [],
61
61
  sideEffects: ['Stops the action and returns a block decision when a secret path is targeted.'],
@@ -322,7 +322,7 @@ function buildHookConfig(hookFiles, profileKey) {
322
322
  const secretsFile = uniqueFiles.find(isSecrets);
323
323
  if (secretsFile) {
324
324
  hookConfig.PreToolUse = [{
325
- matcher: 'Read|Write|Edit',
325
+ matcher: 'Read|Write|Edit|Bash',
326
326
  hooks: [{
327
327
  type: 'command',
328
328
  command: hookCommand(secretsFile),
package/src/mcp-server.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Nerviq MCP Server
4
4
  *
package/src/server.js CHANGED
@@ -18,6 +18,10 @@ const SUPPORTED_PLATFORMS = new Set([
18
18
  'opencode',
19
19
  ]);
20
20
 
21
+ function envelope(data) {
22
+ return { data, meta: { version, timestamp: new Date().toISOString() } };
23
+ }
24
+
21
25
  function sendJson(res, statusCode, payload) {
22
26
  const body = JSON.stringify(payload, null, 2);
23
27
  res.writeHead(statusCode, {
@@ -57,6 +61,11 @@ function createServer(options = {}) {
57
61
  const baseDir = path.resolve(options.baseDir || process.cwd());
58
62
 
59
63
  return http.createServer(async (req, res) => {
64
+ res.setHeader('Access-Control-Allow-Origin', '*');
65
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
66
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
67
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
68
+
60
69
  const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
61
70
 
62
71
  if (req.method !== 'GET') {
@@ -66,16 +75,16 @@ function createServer(options = {}) {
66
75
 
67
76
  try {
68
77
  if (requestUrl.pathname === '/api/health') {
69
- sendJson(res, 200, {
78
+ sendJson(res, 200, envelope({
70
79
  status: 'ok',
71
80
  version,
72
81
  checks: getCatalog().length,
73
- });
82
+ }));
74
83
  return;
75
84
  }
76
85
 
77
86
  if (requestUrl.pathname === '/api/catalog') {
78
- sendJson(res, 200, getCatalog());
87
+ sendJson(res, 200, envelope(getCatalog()));
79
88
  return;
80
89
  }
81
90
 
@@ -83,14 +92,14 @@ function createServer(options = {}) {
83
92
  const dir = resolveRequestDir(baseDir, requestUrl.searchParams.get('dir'));
84
93
  const platform = normalizePlatform(requestUrl.searchParams.get('platform'));
85
94
  const result = await audit({ dir, platform, silent: true });
86
- sendJson(res, 200, result);
95
+ sendJson(res, 200, envelope(result));
87
96
  return;
88
97
  }
89
98
 
90
99
  if (requestUrl.pathname === '/api/harmony') {
91
100
  const dir = resolveRequestDir(baseDir, requestUrl.searchParams.get('dir'));
92
101
  const result = await harmonyAudit({ dir, silent: true });
93
- sendJson(res, 200, result);
102
+ sendJson(res, 200, envelope(result));
94
103
  return;
95
104
  }
96
105
 
package/src/setup.js CHANGED
@@ -10,6 +10,7 @@ const { ProjectContext } = require('./context');
10
10
  const { audit } = require('./audit');
11
11
  const { buildSettingsForProfile } = require('./governance');
12
12
  const { getMcpPackPreflight } = require('./mcp-packs');
13
+ const { writeRollbackArtifact } = require('./activity');
13
14
  const { setupCodex } = require('./codex/setup');
14
15
 
15
16
  // ============================================================
@@ -797,14 +798,21 @@ try {
797
798
  } catch (e) { /* linter not available or failed - non-blocking */ }
798
799
  `,
799
800
  'protect-secrets.js': `#!/usr/bin/env node
800
- // PreToolUse hook - blocks reads of secret files
801
+ // PreToolUse hook - blocks reads of secret files (Read/Write/Edit AND Bash)
801
802
  let input = '';
802
803
  process.stdin.on('data', d => input += d);
803
804
  process.stdin.on('end', () => {
804
805
  try {
805
806
  const data = JSON.parse(input);
807
+ // Check file_path (for Read/Write/Edit)
806
808
  const fp = (data.tool_input && data.tool_input.file_path) || '';
807
- if (/\\.env$|\\.env\\.|secrets[\\/\\\\]|credentials|\\.pem$|\\.key$/i.test(fp)) {
809
+ // Check command (for Bash)
810
+ const cmd = (data.tool_input && data.tool_input.command) || '';
811
+
812
+ const secretPattern = /\\.env($|\\.)|secrets[\\/\\\\]|credentials|\\.pem$|\\.key$/i;
813
+ const bashSecretPattern = /\\bcat\\s+\\.env|\\bless\\s+\\.env|\\bhead\\s+\\.env|\\btail\\s+\\.env|\\bgrep\\b.*\\.env|\\bcp\\s+\\.env|\\bmv\\s+\\.env|\\bbase64\\s+\\.env|\\bxxd\\s+\\.env|secrets\\/|credentials|\\.pem\\b|\\.key\\b/i;
814
+
815
+ if (secretPattern.test(fp) || bashSecretPattern.test(cmd)) {
808
816
  console.log(JSON.stringify({ decision: 'block', reason: 'Blocked: accessing secret/credential files is not allowed.' }));
809
817
  } else {
810
818
  console.log(JSON.stringify({ decision: 'allow' }));
@@ -1143,6 +1151,17 @@ async function setup(options) {
1143
1151
  const mcpPreflightWarnings = getMcpPackPreflight(options.mcpPacks || [])
1144
1152
  .filter(item => item.missingEnvVars.length > 0);
1145
1153
 
1154
+ // Snapshot settings.json before any changes for rollback support
1155
+ const settingsPathForSnapshot = path.join(options.dir, '.claude/settings.json');
1156
+ let settingsSnapshotBefore = null;
1157
+ if (fs.existsSync(settingsPathForSnapshot)) {
1158
+ try {
1159
+ settingsSnapshotBefore = fs.readFileSync(settingsPathForSnapshot, 'utf8');
1160
+ } catch (_) {
1161
+ // Ignore read errors
1162
+ }
1163
+ }
1164
+
1146
1165
  function log(message = '') {
1147
1166
  if (!silent) {
1148
1167
  console.log(message);
@@ -1260,7 +1279,17 @@ async function setup(options) {
1260
1279
  }
1261
1280
  // Merge all fields from newSettings into existing, preserving existing values
1262
1281
  if (newSettings.hooks) existingSettings.hooks = newSettings.hooks;
1263
- if (newSettings.permissions) existingSettings.permissions = { ...existingSettings.permissions, ...newSettings.permissions };
1282
+ if (newSettings.permissions) {
1283
+ existingSettings.permissions = existingSettings.permissions || {};
1284
+ // MERGE deny rules: keep existing + add new (deduplicate)
1285
+ const existingDeny = existingSettings.permissions.deny || [];
1286
+ const newDeny = newSettings.permissions.deny || [];
1287
+ existingSettings.permissions.deny = [...new Set([...existingDeny, ...newDeny])];
1288
+ // Only set defaultMode if not already set
1289
+ if (!existingSettings.permissions.defaultMode && newSettings.permissions.defaultMode) {
1290
+ existingSettings.permissions.defaultMode = newSettings.permissions.defaultMode;
1291
+ }
1292
+ }
1264
1293
  if (newSettings.mcpServers) existingSettings.mcpServers = { ...existingSettings.mcpServers, ...newSettings.mcpServers };
1265
1294
  if (newSettings.nerviqSetup) existingSettings.nerviqSetup = { ...existingSettings.nerviqSetup, ...newSettings.nerviqSetup };
1266
1295
  fs.writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2), 'utf8');
@@ -1302,6 +1331,29 @@ async function setup(options) {
1302
1331
  log(' Run \x1b[1mnpx nerviq audit\x1b[0m to check your score.');
1303
1332
  log('');
1304
1333
 
1334
+ // Write rollback artifact so setup can be undone
1335
+ let rollbackId = null;
1336
+ if (writtenFiles.length > 0) {
1337
+ const patchedFiles = [];
1338
+ // If settings.json was modified (not newly created), record the before-snapshot
1339
+ if (settingsSnapshotBefore !== null && writtenFiles.includes('.claude/settings.json')) {
1340
+ patchedFiles.push({
1341
+ file: '.claude/settings.json',
1342
+ before: settingsSnapshotBefore,
1343
+ });
1344
+ }
1345
+ const rollbackArtifact = writeRollbackArtifact(options.dir, {
1346
+ sourcePlan: 'setup',
1347
+ createdFiles: writtenFiles.filter(f => {
1348
+ // Exclude patched files from createdFiles list
1349
+ return !patchedFiles.some(p => p.file === f);
1350
+ }),
1351
+ patchedFiles,
1352
+ rollbackInstructions: ['Use nerviq rollback to undo this setup'],
1353
+ });
1354
+ rollbackId = rollbackArtifact.id;
1355
+ }
1356
+
1305
1357
  return {
1306
1358
  created,
1307
1359
  skipped,
@@ -1309,6 +1361,7 @@ async function setup(options) {
1309
1361
  preservedFiles,
1310
1362
  stacks,
1311
1363
  mcpPreflightWarnings,
1364
+ rollbackId,
1312
1365
  };
1313
1366
  }
1314
1367