@nerviq/cli 1.10.0 → 1.11.0

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/src/techniques.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const { collectClaudeDenyRules } = require('./permission-rules');
9
10
  const {
10
11
  getClaudeInstructionBundle,
11
12
  hasDocumentedVerificationGuidance,
@@ -560,19 +561,19 @@ const TECHNIQUES = {
560
561
  template: null
561
562
  },
562
563
 
563
- noSecretsInClaude: {
564
- id: 1039,
565
- name: 'CLAUDE.md has no embedded API keys',
566
- check: (ctx) => {
567
- const md = ctx.claudeMdContent() || '';
568
- return !containsEmbeddedSecret(md);
569
- },
570
- impact: 'critical',
571
- rating: 5,
572
- category: 'git',
573
- fix: 'Remove API keys from CLAUDE.md. Use environment variables or .env files instead.',
574
- template: null
575
- },
564
+ noSecretsInClaude: {
565
+ id: 1039,
566
+ name: 'CLAUDE.md has no embedded secrets',
567
+ check: (ctx) => {
568
+ const md = ctx.claudeMdContent() || '';
569
+ return !containsEmbeddedSecret(md);
570
+ },
571
+ impact: 'critical',
572
+ rating: 5,
573
+ category: 'git',
574
+ fix: 'Remove hardcoded secrets, tokens, private keys, and connection strings from CLAUDE.md. Use environment variables or external secret stores instead.',
575
+ template: null
576
+ },
576
577
 
577
578
  // ============================================================
578
579
  // === WORKFLOW (category: 'workflow') =========================
@@ -717,16 +718,13 @@ const TECHNIQUES = {
717
718
  template: null
718
719
  },
719
720
 
720
- permissionDeny: {
721
- id: 2401,
722
- name: 'Deny rules configured in permissions',
723
- check: (ctx) => {
724
- const settings = ctx.jsonFile('.claude/settings.local.json') || ctx.jsonFile('.claude/settings.json');
725
- if (!settings || !settings.permissions) return false;
726
- const deny = settings.permissions.deny;
727
- return Array.isArray(deny) && deny.length > 0;
728
- },
729
- impact: 'high',
721
+ permissionDeny: {
722
+ id: 2401,
723
+ name: 'Deny rules configured in permissions',
724
+ check: (ctx) => {
725
+ return collectClaudeDenyRules(ctx).length > 0;
726
+ },
727
+ impact: 'high',
730
728
  rating: 5,
731
729
  category: 'security',
732
730
  fix: 'Add permissions.deny rules to block dangerous operations (e.g. rm -rf, dropping databases).',
@@ -754,18 +752,19 @@ const TECHNIQUES = {
754
752
  template: null
755
753
  },
756
754
 
757
- secretsProtection: {
758
- id: 1096,
759
- name: 'Secrets protection configured',
760
- check: (ctx) => {
761
- // Prefer shared settings.json (committed) over local override
762
- const settings = ctx.jsonFile('.claude/settings.json') || ctx.jsonFile('.claude/settings.local.json');
763
- if (!settings || !settings.permissions) return false;
764
- const deny = JSON.stringify(settings.permissions.deny || []);
765
- const hasDeny = deny.includes('.env') || deny.includes('secrets');
766
- // Fail if allow includes "*" (overly broad — bypasses deny rules)
767
- const allow = settings.permissions.allow || [];
768
- if (Array.isArray(allow) && allow.includes('*')) return false;
755
+ secretsProtection: {
756
+ id: 1096,
757
+ name: 'Secrets protection configured',
758
+ check: (ctx) => {
759
+ const shared = ctx.jsonFile('.claude/settings.json');
760
+ const local = ctx.jsonFile('.claude/settings.local.json');
761
+ const settings = shared || local;
762
+ if (!settings || !settings.permissions) return false;
763
+ const denyRules = collectClaudeDenyRules(ctx);
764
+ const hasDeny = denyRules.some((rule) => rule.protectsSecrets);
765
+ // Fail if allow includes "*" (overly broad — bypasses deny rules)
766
+ const allow = settings.permissions.allow || [];
767
+ if (Array.isArray(allow) && allow.includes('*')) return false;
769
768
  return hasDeny;
770
769
  },
771
770
  impact: 'critical',
@@ -1527,16 +1526,13 @@ const TECHNIQUES = {
1527
1526
  template: null
1528
1527
  },
1529
1528
 
1530
- denyRulesDepth: {
1531
- id: 2014,
1532
- name: 'Deny rules cover 3+ patterns',
1533
- check: (ctx) => {
1534
- const shared = ctx.jsonFile('.claude/settings.json');
1535
- const local = ctx.jsonFile('.claude/settings.local.json');
1536
- const deny = (shared?.permissions?.deny || []).concat(local?.permissions?.deny || []);
1537
- return deny.length >= 3;
1538
- },
1539
- impact: 'high', rating: 4, category: 'security',
1529
+ denyRulesDepth: {
1530
+ id: 2014,
1531
+ name: 'Deny rules cover 3+ patterns',
1532
+ check: (ctx) => {
1533
+ return collectClaudeDenyRules(ctx).length >= 3;
1534
+ },
1535
+ impact: 'high', rating: 4, category: 'security',
1540
1536
  fix: 'Add at least 3 deny rules: rm -rf, force-push, and .env reads. More patterns = safer Claude.',
1541
1537
  template: null
1542
1538
  },
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const TERMINOLOGY = {
4
+ governance: {
5
+ label: 'governance',
6
+ description: 'the rollout safety layer: permissions, hooks, profiles, and policy packs',
7
+ },
8
+ hooks: {
9
+ label: 'hooks',
10
+ description: 'auto-run checks or scripts triggered before or after agent tool actions',
11
+ },
12
+ denyRules: {
13
+ label: 'deny rules',
14
+ description: 'explicit blocks for risky reads or commands like .env access or rm -rf',
15
+ },
16
+ mcp: {
17
+ label: 'MCP',
18
+ description: 'live external tool connectors for docs, APIs, databases, and other systems',
19
+ },
20
+ };
21
+
22
+ const TERM_ORDER = ['governance', 'hooks', 'denyRules', 'mcp'];
23
+
24
+ function normalizeTermKeys(keys = []) {
25
+ const seen = new Set();
26
+ for (const key of keys) {
27
+ if (!TERMINOLOGY[key]) continue;
28
+ seen.add(key);
29
+ }
30
+ return TERM_ORDER.filter((key) => seen.has(key));
31
+ }
32
+
33
+ function collectAuditTerminology(result = {}) {
34
+ const terms = new Set();
35
+ const texts = [];
36
+
37
+ for (const item of result.topNextActions || []) {
38
+ texts.push(item.name || '', item.fix || '', item.why || '', item.module || '', ...(item.signals || []));
39
+ }
40
+
41
+ for (const item of result.results || []) {
42
+ if (item.passed === false) {
43
+ texts.push(item.key || '', item.name || '', item.fix || '', item.category || '');
44
+ }
45
+ }
46
+
47
+ const blob = texts.join('\n');
48
+ if (/\bhook/i.test(blob)) terms.add('hooks');
49
+ if (/\bdeny rules?\b|permissions?\.deny|bypasspermissions|\.env access|rm -rf/i.test(blob)) terms.add('denyRules');
50
+ if (/\bmcp\b|context7|external tool/i.test(blob)) terms.add('mcp');
51
+ if (/\bgovernance\b|policy pack|permission profile/i.test(blob) || terms.size > 0) terms.add('governance');
52
+
53
+ return normalizeTermKeys([...terms]);
54
+ }
55
+
56
+ function formatTerminologyLines(keys, options = {}) {
57
+ const normalized = normalizeTermKeys(keys);
58
+ if (normalized.length === 0) return [];
59
+ const title = options.title || ' Terms used here:';
60
+ const indent = options.indent || ' ';
61
+ const bullet = options.bullet || '-';
62
+
63
+ return [
64
+ title,
65
+ ...normalized.map((key) => `${indent}${bullet} ${TERMINOLOGY[key].label}: ${TERMINOLOGY[key].description}`),
66
+ ];
67
+ }
68
+
69
+ module.exports = {
70
+ TERMINOLOGY,
71
+ collectAuditTerminology,
72
+ formatTerminologyLines,
73
+ };
@@ -0,0 +1,35 @@
1
+ function splitIdentifierSegments(value) {
2
+ return String(value || '')
3
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
4
+ .replace(/[_./:-]+/g, ' ')
5
+ .split(/\s+/)
6
+ .filter(Boolean);
7
+ }
8
+
9
+ function estimateSegmentTokens(segment) {
10
+ const charCount = [...String(segment || '')].length;
11
+ return Math.max(1, Math.ceil(charCount / 4));
12
+ }
13
+
14
+ function estimateTokenCount(text) {
15
+ if (typeof text !== 'string' || !text) return 0;
16
+
17
+ const parts = text.match(/[\p{L}\p{N}_]+|[^\s]/gu) || [];
18
+ let total = 0;
19
+
20
+ for (const part of parts) {
21
+ if (/^[\p{L}\p{N}_]+$/u.test(part)) {
22
+ const segments = splitIdentifierSegments(part);
23
+ total += segments.reduce((sum, segment) => sum + estimateSegmentTokens(segment), 0);
24
+ continue;
25
+ }
26
+
27
+ total += 1;
28
+ }
29
+
30
+ return total;
31
+ }
32
+
33
+ module.exports = {
34
+ estimateTokenCount,
35
+ };
package/src/workspace.js CHANGED
@@ -177,6 +177,104 @@ function summarizeAuditResult(result, scoreType, scope) {
177
177
  };
178
178
  }
179
179
 
180
+ function summarizeWorkspaceEntry(result, workspacePath, absPath, platform) {
181
+ const stackKeys = (result.stacks || []).map((item) => item.key);
182
+ const stackLabels = (result.stacks || []).map((item) => item.label);
183
+ return {
184
+ name: path.basename(workspacePath),
185
+ workspace: workspacePath,
186
+ dir: absPath,
187
+ platform,
188
+ stackKeys,
189
+ stackLabels,
190
+ workspaceProfile: classifyWorkspaceProfile(stackKeys),
191
+ ...summarizeAuditResult(result, 'workspace-live-audit', 'workspace-package'),
192
+ };
193
+ }
194
+
195
+ function classifyWorkspaceProfile(stackKeys) {
196
+ const keys = new Set(Array.isArray(stackKeys) ? stackKeys : []);
197
+ const matchAny = (candidates) => candidates.some((candidate) => keys.has(candidate));
198
+
199
+ if (matchAny(['go'])) {
200
+ return { key: 'go-workspace', label: 'Go workspace' };
201
+ }
202
+ if (matchAny(['python', 'django', 'fastapi'])) {
203
+ return { key: 'python-workspace', label: 'Python workspace' };
204
+ }
205
+ if (matchAny(['dotnet'])) {
206
+ return { key: 'dotnet-workspace', label: '.NET workspace' };
207
+ }
208
+ if (matchAny(['java', 'spring'])) {
209
+ return { key: 'java-workspace', label: 'Java workspace' };
210
+ }
211
+ if (matchAny(['flutter', 'dart'])) {
212
+ return { key: 'flutter-workspace', label: 'Flutter workspace' };
213
+ }
214
+ if (matchAny(['swift'])) {
215
+ return { key: 'swift-workspace', label: 'Swift workspace' };
216
+ }
217
+ if (matchAny(['kotlin'])) {
218
+ return { key: 'kotlin-workspace', label: 'Kotlin workspace' };
219
+ }
220
+ if (matchAny(['react', 'nextjs', 'node', 'typescript', 'javascript', 'nestjs', 'vue', 'angular', 'svelte'])) {
221
+ return { key: 'node-workspace', label: 'Node / JS workspace' };
222
+ }
223
+
224
+ return { key: 'general-workspace', label: 'General workspace' };
225
+ }
226
+
227
+ function buildProfileBreakdown(results) {
228
+ const grouped = new Map();
229
+
230
+ for (const item of results) {
231
+ const profileKey = item.workspaceProfile?.key || 'general-workspace';
232
+ const profileLabel = item.workspaceProfile?.label || 'General workspace';
233
+ if (!grouped.has(profileKey)) {
234
+ grouped.set(profileKey, {
235
+ profileKey,
236
+ profileLabel,
237
+ scoreType: 'workspace-live-audit',
238
+ workspaceCount: 0,
239
+ workspaces: [],
240
+ stackLabels: new Set(),
241
+ scores: [],
242
+ totals: [],
243
+ });
244
+ }
245
+
246
+ const entry = grouped.get(profileKey);
247
+ entry.workspaceCount += 1;
248
+ entry.workspaces.push(item.workspace);
249
+ for (const label of item.stackLabels || []) {
250
+ entry.stackLabels.add(label);
251
+ }
252
+ if (typeof item.score === 'number') {
253
+ entry.scores.push(item.score);
254
+ }
255
+ if (typeof item.total === 'number') {
256
+ entry.totals.push(item.total);
257
+ }
258
+ }
259
+
260
+ return [...grouped.values()]
261
+ .map((entry) => ({
262
+ profileKey: entry.profileKey,
263
+ profileLabel: entry.profileLabel,
264
+ scoreType: 'workspace-live-audit',
265
+ workspaceCount: entry.workspaceCount,
266
+ averageScore: entry.scores.length > 0
267
+ ? Math.round(entry.scores.reduce((sum, value) => sum + value, 0) / entry.scores.length)
268
+ : 0,
269
+ averageTotal: entry.totals.length > 0
270
+ ? Math.round(entry.totals.reduce((sum, value) => sum + value, 0) / entry.totals.length)
271
+ : 0,
272
+ stackLabels: [...entry.stackLabels].sort(),
273
+ workspaces: entry.workspaces.sort(),
274
+ }))
275
+ .sort((left, right) => left.profileLabel.localeCompare(right.profileLabel));
276
+ }
277
+
180
278
  async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
181
279
  const { audit } = require('./audit');
182
280
  const rootDir = path.resolve(dir);
@@ -207,14 +305,7 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
207
305
  const absPath = path.join(rootDir, workspacePath);
208
306
  try {
209
307
  const result = await audit({ dir: absPath, platform, silent: true });
210
- results.push({
211
- name: path.basename(workspacePath),
212
- workspace: workspacePath,
213
- dir: absPath,
214
- platform,
215
- ...summarizeAuditResult(result, 'workspace-live-audit', 'workspace-package'),
216
- result,
217
- });
308
+ results.push(summarizeWorkspaceEntry(result, workspacePath, absPath, platform));
218
309
  } catch (error) {
219
310
  results.push({
220
311
  name: path.basename(workspacePath),
@@ -227,6 +318,9 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
227
318
  passed: 0,
228
319
  total: 0,
229
320
  topAction: null,
321
+ stackKeys: [],
322
+ stackLabels: [],
323
+ workspaceProfile: { key: 'general-workspace', label: 'General workspace' },
230
324
  error: error.message,
231
325
  });
232
326
  }
@@ -238,6 +332,7 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
238
332
  : 0;
239
333
  const maxScore = validScores.length > 0 ? Math.max(...validScores) : 0;
240
334
  const minScore = validScores.length > 0 ? Math.min(...validScores) : 0;
335
+ const profileBreakdown = buildProfileBreakdown(results);
241
336
 
242
337
  return {
243
338
  summaryType: 'monorepo-workspace-audit',
@@ -254,10 +349,12 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
254
349
  maxScore,
255
350
  minScore,
256
351
  },
352
+ profileBreakdown,
257
353
  scoreSemantics: {
258
354
  rootGovernance: 'Root repo live audit for shared instructions, hooks, permissions, and top-level governance files.',
259
355
  workspaceAggregate: 'Average of the selected workspace live audit scores. This is a package coverage rollup, not the root repo score.',
260
356
  workspaceEntries: 'Each workspace row is a package-level live audit. Package scores can differ from the root governance score for legitimate reasons.',
357
+ workspaceProfiles: 'Workspace totals can differ because each package uses a stack-specific check profile based on detected languages and frameworks.',
261
358
  },
262
359
  workspaces: results,
263
360
  detectedWorkspaces: workspacePaths,