@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/README.md +80 -29
- package/bin/cli.js +229 -110
- package/package.json +1 -1
- package/src/activity.js +185 -59
- package/src/aider/freshness.js +28 -25
- package/src/analyze.js +3 -1
- package/src/anti-patterns.js +4 -2
- package/src/audit.js +100 -74
- package/src/benchmark.js +15 -10
- package/src/governance.js +13 -7
- package/src/index.js +2 -1
- package/src/integrations.js +102 -55
- package/src/permission-rules.js +218 -0
- package/src/secret-patterns.js +9 -0
- package/src/server.js +398 -3
- package/src/setup.js +2 -2
- package/src/techniques.js +41 -45
- package/src/terminology.js +73 -0
- package/src/token-estimate.js +35 -0
- package/src/workspace.js +105 -8
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
|
|
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
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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,
|