@nerviq/cli 1.26.0 → 1.27.1
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/CHANGELOG.md +1407 -0
- package/README.md +4 -4
- package/SECURITY.md +82 -0
- package/bin/cli.js +13 -1
- package/contracts/audit-webhook-event.schema.json +138 -0
- package/contracts/pack-contract.schema.json +15 -0
- package/contracts/technique-contract.schema.json +18 -0
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/api-reference.md +356 -0
- package/docs/autofix.md +64 -0
- package/docs/bitbucket-pipe.yml +57 -0
- package/docs/case-studies.md +149 -0
- package/docs/category-definition-kit.md +56 -0
- package/docs/ci-integration.md +127 -0
- package/docs/claude-code-style.md +24 -0
- package/docs/claude-maintainer-ops.md +19 -0
- package/docs/external-validation.md +78 -0
- package/docs/first-tier-integration-gate.md +59 -0
- package/docs/getting-started.md +119 -0
- package/docs/gitlab-ci-template.yml +54 -0
- package/docs/index.html +597 -0
- package/docs/integration-contracts.md +287 -0
- package/docs/license-faq.md +53 -0
- package/docs/maintenance.md +155 -0
- package/docs/methodology.md +236 -0
- package/docs/new-platform-guide.md +202 -0
- package/docs/open-vsx-publishing.md +46 -0
- package/docs/platform-change-ingestion.md +46 -0
- package/docs/plugins.md +101 -0
- package/docs/pre-commit.md +58 -0
- package/docs/security-model.md +63 -0
- package/docs/shallow-risk.md +246 -0
- package/docs/versioning-policy.md +63 -0
- package/docs/why-nerviq.md +82 -0
- package/package.json +7 -2
- package/sdk/README.md +190 -0
- package/src/audit/layers.js +180 -179
- package/src/audit.js +118 -48
- package/src/codex/setup.js +3 -2
- package/src/formatters/csv.js +86 -85
- package/src/formatters/junit.js +123 -103
- package/src/formatters/markdown.js +164 -135
- package/src/gemini/setup.js +3 -2
- package/src/init.js +4 -3
- package/src/opencode/context.js +42 -3
- package/src/opencode/techniques.js +198 -142
- package/src/output-icons.js +44 -0
- package/src/setup/runtime.js +6 -5
- package/src/setup.js +4 -3
- package/src/shallow-risk/index.js +56 -0
- package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -0
- package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -0
- package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -0
- package/src/shallow-risk/patterns/agent-config-missing-file.js +72 -0
- package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -0
- package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -0
- package/src/shallow-risk/patterns/hook-script-missing.js +70 -0
- package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -0
- package/src/shallow-risk/shared.js +520 -0
|
@@ -163,15 +163,69 @@ function hasCommandMention(content, category) {
|
|
|
163
163
|
return false;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function docsBundle(ctx) {
|
|
167
|
-
return `${agentsContent(ctx)}\n${ctx.fileContent('README.md') || ''}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
166
|
+
function docsBundle(ctx) {
|
|
167
|
+
return `${agentsContent(ctx)}\n${ctx.fileContent('README.md') || ''}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasExplicitModelSetting(value) {
|
|
171
|
+
if (!value || typeof value !== 'object') return false;
|
|
172
|
+
if (Array.isArray(value)) return value.some((item) => hasExplicitModelSetting(item));
|
|
173
|
+
if (typeof value.model === 'string' && value.model.trim()) return true;
|
|
174
|
+
return Object.values(value).some((item) => hasExplicitModelSetting(item));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function configuredPluginRefs(ctx) {
|
|
178
|
+
const config = ctx.configJson();
|
|
179
|
+
if (!config.ok || !config.data) return [];
|
|
180
|
+
const refs = config.data.plugin || config.data.plugins || [];
|
|
181
|
+
return Array.isArray(refs) ? refs.filter(Boolean) : [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractSkillDescription(content) {
|
|
185
|
+
if (!content) return null;
|
|
186
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
187
|
+
if (!frontmatter) return null;
|
|
188
|
+
|
|
189
|
+
const lines = frontmatter[1].split(/\r?\n/);
|
|
190
|
+
for (let index = 0; index < lines.length; index++) {
|
|
191
|
+
const line = lines[index];
|
|
192
|
+
const match = line.match(/^description:\s*(.*)$/);
|
|
193
|
+
if (!match) continue;
|
|
194
|
+
|
|
195
|
+
const rawValue = match[1].trim();
|
|
196
|
+
if (rawValue === '|' || rawValue === '>') {
|
|
197
|
+
const block = [];
|
|
198
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
199
|
+
const candidate = lines[next];
|
|
200
|
+
if (candidate.trim() && !/^\s/.test(candidate)) break;
|
|
201
|
+
block.push(candidate.replace(/^\s{2}/, ''));
|
|
202
|
+
}
|
|
203
|
+
return block.join('\n').trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return rawValue.replace(/^['"]|['"]$/g, '').trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function preferredSkillRoot(ctx) {
|
|
213
|
+
const candidates = [
|
|
214
|
+
'.opencode/skills',
|
|
215
|
+
'.opencode/skill',
|
|
216
|
+
'.claude/skills',
|
|
217
|
+
'.agents/skills',
|
|
218
|
+
'.opencode/commands',
|
|
219
|
+
];
|
|
220
|
+
const found = candidates.find((candidate) => ctx.hasDir(candidate));
|
|
221
|
+
return `${(found || '.opencode/skills').replace(/\\/g, '/')}/`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function repoLooksRegulated(ctx) {
|
|
225
|
+
const filenames = ctx.files.join('\n');
|
|
226
|
+
const packageJson = ctx.fileContent('package.json') || '';
|
|
227
|
+
const readme = ctx.fileContent('README.md') || '';
|
|
228
|
+
const combined = `${filenames}\n${packageJson}\n${readme}`;
|
|
175
229
|
|
|
176
230
|
const strongSignals = /\bhipaa\b|\bphi\b|\bpci\b|\bsoc2\b|\biso[- ]?27001\b|\bcompliance\b|\bhealth(?:care)?\b|\bmedical\b|\bbank(?:ing)?\b|\bpayments?\b|\bfintech\b/i;
|
|
177
231
|
if (strongSignals.test(combined)) return true;
|
|
@@ -364,35 +418,35 @@ const OPENCODE_TECHNIQUES = {
|
|
|
364
418
|
line: () => 1,
|
|
365
419
|
},
|
|
366
420
|
|
|
367
|
-
opencodeModelExplicit: {
|
|
368
|
-
id: 'OC-B04',
|
|
369
|
-
name: 'model is set explicitly (not relying on silent default)',
|
|
370
|
-
check: (ctx) => {
|
|
371
|
-
const config = ctx.configJson();
|
|
372
|
-
if (!config.ok || !config.data) return null;
|
|
373
|
-
return
|
|
374
|
-
},
|
|
375
|
-
impact: 'medium',
|
|
376
|
-
rating: 3,
|
|
377
|
-
category: 'config',
|
|
378
|
-
fix: 'Set "model" explicitly in opencode.json to avoid relying on silent provider defaults.',
|
|
421
|
+
opencodeModelExplicit: {
|
|
422
|
+
id: 'OC-B04',
|
|
423
|
+
name: 'model is set explicitly (not relying on silent default)',
|
|
424
|
+
check: (ctx) => {
|
|
425
|
+
const config = ctx.configJson();
|
|
426
|
+
if (!config.ok || !config.data) return null;
|
|
427
|
+
return hasExplicitModelSetting(config.data) ? true : null;
|
|
428
|
+
},
|
|
429
|
+
impact: 'medium',
|
|
430
|
+
rating: 3,
|
|
431
|
+
category: 'config',
|
|
432
|
+
fix: 'Set "model" explicitly in opencode.json to avoid relying on silent provider defaults.',
|
|
379
433
|
template: 'opencode-config',
|
|
380
434
|
file: (ctx) => configFileName(ctx),
|
|
381
435
|
line: () => null,
|
|
382
436
|
},
|
|
383
437
|
|
|
384
|
-
opencodeSmallModelSet: {
|
|
385
|
-
id: 'OC-B05',
|
|
386
|
-
name: 'small_model is set for task delegation',
|
|
387
|
-
check: (ctx) => {
|
|
388
|
-
const config = ctx.configJson();
|
|
389
|
-
if (!config.ok || !config.data) return null;
|
|
390
|
-
return
|
|
391
|
-
},
|
|
392
|
-
impact: 'medium',
|
|
393
|
-
rating: 3,
|
|
394
|
-
category: 'config',
|
|
395
|
-
fix: 'Set "small_model" in opencode.json for efficient task delegation and cost control.',
|
|
438
|
+
opencodeSmallModelSet: {
|
|
439
|
+
id: 'OC-B05',
|
|
440
|
+
name: 'small_model is set for task delegation',
|
|
441
|
+
check: (ctx) => {
|
|
442
|
+
const config = ctx.configJson();
|
|
443
|
+
if (!config.ok || !config.data) return null;
|
|
444
|
+
return config.data.small_model ? true : null;
|
|
445
|
+
},
|
|
446
|
+
impact: 'medium',
|
|
447
|
+
rating: 3,
|
|
448
|
+
category: 'config',
|
|
449
|
+
fix: 'Set "small_model" in opencode.json for efficient task delegation and cost control.',
|
|
396
450
|
template: 'opencode-config',
|
|
397
451
|
file: (ctx) => configFileName(ctx),
|
|
398
452
|
line: () => null,
|
|
@@ -1104,22 +1158,22 @@ const OPENCODE_TECHNIQUES = {
|
|
|
1104
1158
|
// I. Skills (5 checks)
|
|
1105
1159
|
// ==============================
|
|
1106
1160
|
|
|
1107
|
-
opencodeSkillDirsExist: {
|
|
1108
|
-
id: 'OC-I01',
|
|
1109
|
-
name: 'Skill directories exist
|
|
1110
|
-
check: (ctx) => {
|
|
1111
|
-
const skillDirs = ctx.skillDirs();
|
|
1112
|
-
if (skillDirs.length === 0) return null;
|
|
1113
|
-
return skillDirs.length > 0;
|
|
1114
|
-
},
|
|
1115
|
-
impact: 'medium',
|
|
1116
|
-
rating: 3,
|
|
1117
|
-
category: 'skills',
|
|
1118
|
-
fix: 'Create skill directories under
|
|
1119
|
-
template: 'opencode-skills',
|
|
1120
|
-
file: () =>
|
|
1121
|
-
line: () => null,
|
|
1122
|
-
},
|
|
1161
|
+
opencodeSkillDirsExist: {
|
|
1162
|
+
id: 'OC-I01',
|
|
1163
|
+
name: 'Skill directories exist in supported OpenCode paths',
|
|
1164
|
+
check: (ctx) => {
|
|
1165
|
+
const skillDirs = ctx.skillDirs();
|
|
1166
|
+
if (skillDirs.length === 0) return null;
|
|
1167
|
+
return skillDirs.length > 0;
|
|
1168
|
+
},
|
|
1169
|
+
impact: 'medium',
|
|
1170
|
+
rating: 3,
|
|
1171
|
+
category: 'skills',
|
|
1172
|
+
fix: 'Create skill directories under `.opencode/skills/` with `SKILL.md` files. `.claude/skills/` and `.agents/skills/` remain compatible, and older `.opencode/commands/<name>/SKILL.md` layouts are still tolerated for backwards compatibility.',
|
|
1173
|
+
template: 'opencode-skills',
|
|
1174
|
+
file: (ctx) => preferredSkillRoot(ctx),
|
|
1175
|
+
line: () => null,
|
|
1176
|
+
},
|
|
1123
1177
|
|
|
1124
1178
|
opencodeSkillFrontmatter: {
|
|
1125
1179
|
id: 'OC-I02',
|
|
@@ -1133,15 +1187,15 @@ const OPENCODE_TECHNIQUES = {
|
|
|
1133
1187
|
if (!/^#\s+/m.test(content)) return false;
|
|
1134
1188
|
}
|
|
1135
1189
|
return true;
|
|
1136
|
-
},
|
|
1137
|
-
impact: 'high',
|
|
1138
|
-
rating: 4,
|
|
1139
|
-
category: 'skills',
|
|
1140
|
-
fix: 'Each SKILL.md needs a title (# heading) and description for skill invocation.',
|
|
1141
|
-
template: 'opencode-skills',
|
|
1142
|
-
file: () =>
|
|
1143
|
-
line: () => null,
|
|
1144
|
-
},
|
|
1190
|
+
},
|
|
1191
|
+
impact: 'high',
|
|
1192
|
+
rating: 4,
|
|
1193
|
+
category: 'skills',
|
|
1194
|
+
fix: 'Each SKILL.md needs a title (# heading) and description for skill invocation.',
|
|
1195
|
+
template: 'opencode-skills',
|
|
1196
|
+
file: (ctx) => preferredSkillRoot(ctx),
|
|
1197
|
+
line: () => null,
|
|
1198
|
+
},
|
|
1145
1199
|
|
|
1146
1200
|
opencodeSkillKebabCase: {
|
|
1147
1201
|
id: 'OC-I03',
|
|
@@ -1151,54 +1205,56 @@ const OPENCODE_TECHNIQUES = {
|
|
|
1151
1205
|
if (skillDirs.length === 0) return null;
|
|
1152
1206
|
return skillDirs.every(name => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name));
|
|
1153
1207
|
},
|
|
1154
|
-
impact: 'medium',
|
|
1155
|
-
rating: 2,
|
|
1156
|
-
category: 'skills',
|
|
1157
|
-
fix: 'Prefer kebab-case for skill names, but treat it as a style recommendation rather than a hard runtime requirement. Current runtime still discovered underscore-based names.',
|
|
1158
|
-
template: 'opencode-skills',
|
|
1159
|
-
file: () =>
|
|
1160
|
-
line: () => null,
|
|
1161
|
-
},
|
|
1162
|
-
|
|
1163
|
-
opencodeSkillDescriptionBounded: {
|
|
1164
|
-
id: 'OC-I04',
|
|
1165
|
-
name: 'Skill descriptions are bounded for implicit invocation context cost',
|
|
1166
|
-
check: (ctx) => {
|
|
1167
|
-
const skillDirs = ctx.skillDirs();
|
|
1168
|
-
if (skillDirs.length === 0) return null;
|
|
1169
|
-
for (const name of skillDirs) {
|
|
1170
|
-
const content = ctx.skillMetadata(name);
|
|
1171
|
-
if (!content) continue;
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
const
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1208
|
+
impact: 'medium',
|
|
1209
|
+
rating: 2,
|
|
1210
|
+
category: 'skills',
|
|
1211
|
+
fix: 'Prefer kebab-case for skill names, but treat it as a style recommendation rather than a hard runtime requirement. Current runtime still discovered underscore-based names.',
|
|
1212
|
+
template: 'opencode-skills',
|
|
1213
|
+
file: (ctx) => preferredSkillRoot(ctx),
|
|
1214
|
+
line: () => null,
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
opencodeSkillDescriptionBounded: {
|
|
1218
|
+
id: 'OC-I04',
|
|
1219
|
+
name: 'Skill descriptions are bounded for implicit invocation context cost',
|
|
1220
|
+
check: (ctx) => {
|
|
1221
|
+
const skillDirs = ctx.skillDirs();
|
|
1222
|
+
if (skillDirs.length === 0) return null;
|
|
1223
|
+
for (const name of skillDirs) {
|
|
1224
|
+
const content = ctx.skillMetadata(name);
|
|
1225
|
+
if (!content) continue;
|
|
1226
|
+
const description = extractSkillDescription(content);
|
|
1227
|
+
if (description && description.length > 3000) return false;
|
|
1228
|
+
}
|
|
1229
|
+
return true;
|
|
1230
|
+
},
|
|
1231
|
+
impact: 'medium',
|
|
1232
|
+
rating: 3,
|
|
1233
|
+
category: 'skills',
|
|
1234
|
+
fix: 'Keep SKILL.md descriptions under 3000 characters to manage implicit invocation context cost.',
|
|
1235
|
+
template: 'opencode-skills',
|
|
1236
|
+
file: (ctx) => preferredSkillRoot(ctx),
|
|
1237
|
+
line: () => null,
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
opencodeSkillCompatPaths: {
|
|
1241
|
+
id: 'OC-I05',
|
|
1242
|
+
name: 'OpenCode skill discovery accepts native and compatible skill trees',
|
|
1243
|
+
check: (ctx) => {
|
|
1244
|
+
const hasNativeSkillTree = ctx.hasDir('.opencode/skills') || ctx.hasDir('.opencode/skill');
|
|
1245
|
+
const hasCompatibleSkillTree = ctx.hasDir('.claude/skills') || ctx.hasDir('.agents/skills');
|
|
1246
|
+
const hasLegacyCommandsSkills = ctx.hasDir('.opencode/commands') && ctx.skillDirs().length > 0;
|
|
1247
|
+
if (!hasNativeSkillTree && !hasCompatibleSkillTree && !hasLegacyCommandsSkills) return null;
|
|
1248
|
+
return hasNativeSkillTree || hasCompatibleSkillTree || hasLegacyCommandsSkills;
|
|
1249
|
+
},
|
|
1250
|
+
impact: 'medium',
|
|
1251
|
+
rating: 3,
|
|
1252
|
+
category: 'skills',
|
|
1253
|
+
fix: 'Use `.opencode/skills/` for native OpenCode skills. `.claude/skills/` and `.agents/skills/` remain compatible, and older `.opencode/commands/<name>/SKILL.md` layouts are kept as legacy compatibility only.',
|
|
1254
|
+
template: 'opencode-skills',
|
|
1255
|
+
file: (ctx) => preferredSkillRoot(ctx),
|
|
1256
|
+
line: () => null,
|
|
1257
|
+
},
|
|
1202
1258
|
|
|
1203
1259
|
// ==============================
|
|
1204
1260
|
// J. Agents & Subagents (4 checks)
|
|
@@ -1498,42 +1554,42 @@ const OPENCODE_TECHNIQUES = {
|
|
|
1498
1554
|
line: () => null,
|
|
1499
1555
|
},
|
|
1500
1556
|
|
|
1501
|
-
opencodeConfigKeysFresh: {
|
|
1502
|
-
id: 'OC-N02',
|
|
1503
|
-
name: 'Config references current OpenCode features (no removed or renamed keys)',
|
|
1504
|
-
check: (ctx) => {
|
|
1505
|
-
const docs = docsBundle(ctx);
|
|
1506
|
-
const config = ctx.configContent();
|
|
1507
|
-
if (!docs.trim() && !config) return null;
|
|
1508
|
-
const combined = `${docs}\n${config || ''}`;
|
|
1509
|
-
return
|
|
1510
|
-
},
|
|
1511
|
-
impact: 'medium',
|
|
1512
|
-
rating: 3,
|
|
1513
|
-
category: 'release-freshness',
|
|
1514
|
-
fix: 'Update stale OpenCode references. Use `opencode.json`/`opencode.jsonc
|
|
1515
|
-
template: 'opencode-config',
|
|
1516
|
-
file: (ctx) => configFileName(ctx),
|
|
1517
|
-
line: () => null,
|
|
1518
|
-
},
|
|
1519
|
-
|
|
1520
|
-
opencodePropagationCompleteness: {
|
|
1521
|
-
id: 'OC-N03',
|
|
1522
|
-
name: 'No dangling surface references (plugins, skills, MCP mentioned but not defined)',
|
|
1523
|
-
check: (ctx) => {
|
|
1524
|
-
const agents = agentsContent(ctx);
|
|
1525
|
-
if (!agents) return null;
|
|
1526
|
-
const issues = [];
|
|
1527
|
-
if (/\bplugins?\b/i.test(agents) && ctx.pluginFiles().length === 0) {
|
|
1528
|
-
issues.push('plugins referenced but .opencode/plugins/ empty');
|
|
1529
|
-
}
|
|
1530
|
-
if (/\bskills?\b/i.test(agents) &&
|
|
1531
|
-
issues.push('skills referenced but .opencode/
|
|
1532
|
-
}
|
|
1533
|
-
const config = ctx.configJson();
|
|
1534
|
-
if (config.ok && config.data && /\bmcp\b/i.test(agents)) {
|
|
1535
|
-
const mcp = config.data.mcp || {};
|
|
1536
|
-
if (Object.keys(mcp).length === 0) {
|
|
1557
|
+
opencodeConfigKeysFresh: {
|
|
1558
|
+
id: 'OC-N02',
|
|
1559
|
+
name: 'Config references current OpenCode features (no removed or renamed keys)',
|
|
1560
|
+
check: (ctx) => {
|
|
1561
|
+
const docs = docsBundle(ctx);
|
|
1562
|
+
const config = ctx.configContent();
|
|
1563
|
+
if (!docs.trim() && !config) return null;
|
|
1564
|
+
const combined = `${docs}\n${config || ''}`;
|
|
1565
|
+
return !/(?:^|[\s`"'(])~\/\.opencode\.json\b|(?:^|[\s`"'(])\.opencode\/config\.json\b|mode\s*->\s*agent|CLAUDE\.md fallback/i.test(combined);
|
|
1566
|
+
},
|
|
1567
|
+
impact: 'medium',
|
|
1568
|
+
rating: 3,
|
|
1569
|
+
category: 'release-freshness',
|
|
1570
|
+
fix: 'Update stale OpenCode references. Use `opencode.json`/`opencode.jsonc` plus the current `.opencode/{agents,commands,plugins,skills}/` directory layout. Treat legacy `~/.opencode.json`, `.opencode/config.json`, and `CLAUDE.md` fallback claims as stale unless you have fresh runtime proof.',
|
|
1571
|
+
template: 'opencode-config',
|
|
1572
|
+
file: (ctx) => configFileName(ctx),
|
|
1573
|
+
line: () => null,
|
|
1574
|
+
},
|
|
1575
|
+
|
|
1576
|
+
opencodePropagationCompleteness: {
|
|
1577
|
+
id: 'OC-N03',
|
|
1578
|
+
name: 'No dangling surface references (plugins, skills, MCP mentioned but not defined)',
|
|
1579
|
+
check: (ctx) => {
|
|
1580
|
+
const agents = agentsContent(ctx);
|
|
1581
|
+
if (!agents) return null;
|
|
1582
|
+
const issues = [];
|
|
1583
|
+
if (/\bplugins?\b/i.test(agents) && ctx.pluginFiles().length === 0 && configuredPluginRefs(ctx).length === 0) {
|
|
1584
|
+
issues.push('plugins referenced but .opencode/plugins/ empty');
|
|
1585
|
+
}
|
|
1586
|
+
if (/\bskills?\b/i.test(agents) && ctx.skillDirs().length === 0) {
|
|
1587
|
+
issues.push('skills referenced but no supported skill tree found (.opencode/skills, .claude/skills, .agents/skills, or legacy .opencode/commands/<name>/SKILL.md)');
|
|
1588
|
+
}
|
|
1589
|
+
const config = ctx.configJson();
|
|
1590
|
+
if (config.ok && config.data && /\bmcp\b/i.test(agents)) {
|
|
1591
|
+
const mcp = config.data.mcp || {};
|
|
1592
|
+
if (Object.keys(mcp).length === 0) {
|
|
1537
1593
|
issues.push('MCP referenced in AGENTS.md but no MCP servers in config');
|
|
1538
1594
|
}
|
|
1539
1595
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const ASCII_ENV = 'NERVIQ_ASCII_OUTPUT';
|
|
2
|
+
|
|
3
|
+
const ASCII_TRUE = new Set(['1', 'true', 'yes', 'on']);
|
|
4
|
+
const ASCII_FALSE = new Set(['0', 'false', 'no', 'off']);
|
|
5
|
+
|
|
6
|
+
const ICONS = {
|
|
7
|
+
ok: { emoji: '✅', ascii: '[OK]' },
|
|
8
|
+
fail: { emoji: '❌', ascii: '[FAIL]' },
|
|
9
|
+
warn: { emoji: '⚠', ascii: '[WARN]' },
|
|
10
|
+
skip: { emoji: '⏭️', ascii: '[SKIP]' },
|
|
11
|
+
delete: { emoji: '🗑️', ascii: '[DEL]' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function parseAsciiOverride(env = process.env) {
|
|
15
|
+
const raw = env && env[ASCII_ENV];
|
|
16
|
+
if (typeof raw !== 'string') return null;
|
|
17
|
+
const normalized = raw.trim().toLowerCase();
|
|
18
|
+
if (ASCII_TRUE.has(normalized)) return true;
|
|
19
|
+
if (ASCII_FALSE.has(normalized)) return false;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function shouldUseAsciiOutput(options = {}) {
|
|
24
|
+
const env = options.env || process.env;
|
|
25
|
+
const platform = options.platform || process.platform;
|
|
26
|
+
const stream = options.stream || process.stdout;
|
|
27
|
+
const override = parseAsciiOverride(env);
|
|
28
|
+
if (override !== null) return override;
|
|
29
|
+
if (!stream || stream.isTTY === false) return true;
|
|
30
|
+
return platform === 'win32';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function icon(name, options = {}) {
|
|
34
|
+
const token = ICONS[name];
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new Error(`Unknown icon token: ${name}`);
|
|
37
|
+
}
|
|
38
|
+
return shouldUseAsciiOutput(options) ? token.ascii : token.emoji;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
icon,
|
|
43
|
+
shouldUseAsciiOutput,
|
|
44
|
+
};
|
package/src/setup/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { buildSettingsForProfile } = require('../governance');
|
|
4
|
+
const { icon } = require('../output-icons');
|
|
4
5
|
|
|
5
6
|
function snapshotSettingsBeforeSetup(dir) {
|
|
6
7
|
const settingsPath = path.join(dir, '.claude/settings.json');
|
|
@@ -53,11 +54,11 @@ function applyTemplateResults({ dir, failedWithTemplates, stacks, ctx, templates
|
|
|
53
54
|
if (!fs.existsSync(fullPath)) {
|
|
54
55
|
fs.writeFileSync(fullPath, result, 'utf8');
|
|
55
56
|
writtenFiles.push(filePath);
|
|
56
|
-
log(` \x1b[32m
|
|
57
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Created ${filePath}`);
|
|
57
58
|
created++;
|
|
58
59
|
} else {
|
|
59
60
|
preservedFiles.push(filePath);
|
|
60
|
-
log(` \x1b[2m
|
|
61
|
+
log(` \x1b[2m${icon('skip')} Skipped ${filePath} (already exists - your version is kept)\x1b[0m`);
|
|
61
62
|
skipped++;
|
|
62
63
|
}
|
|
63
64
|
} else if (typeof result === 'object') {
|
|
@@ -84,7 +85,7 @@ function applyTemplateResults({ dir, failedWithTemplates, stacks, ctx, templates
|
|
|
84
85
|
if (!fs.existsSync(filePath)) {
|
|
85
86
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
86
87
|
writtenFiles.push(path.relative(dir, filePath));
|
|
87
|
-
log(` \x1b[32m
|
|
88
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Created ${path.relative(dir, filePath)}`);
|
|
88
89
|
created++;
|
|
89
90
|
} else {
|
|
90
91
|
preservedFiles.push(path.relative(dir, filePath));
|
|
@@ -151,10 +152,10 @@ function mergeGeneratedHookSettings({ dir, profile, mcpPacks, writtenFiles, pres
|
|
|
151
152
|
fs.writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2), 'utf8');
|
|
152
153
|
if (!writtenFiles.includes('.claude/settings.json') && !preservedFiles.includes('.claude/settings.json')) {
|
|
153
154
|
writtenFiles.push('.claude/settings.json');
|
|
154
|
-
log(` \x1b[32m
|
|
155
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Updated .claude/settings.json (hooks registered)`);
|
|
155
156
|
created++;
|
|
156
157
|
} else {
|
|
157
|
-
log(` \x1b[32m
|
|
158
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Merged hooks into existing .claude/settings.json`);
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
return {
|
package/src/setup.js
CHANGED
|
@@ -9,8 +9,9 @@ const { TECHNIQUES, STACKS } = require('./techniques');
|
|
|
9
9
|
const { ProjectContext } = require('./context');
|
|
10
10
|
const { getMcpPackPreflight } = require('./mcp-packs');
|
|
11
11
|
const { writeRollbackArtifact } = require('./activity');
|
|
12
|
-
const { setupCodex } = require('./codex/setup');
|
|
13
|
-
const {
|
|
12
|
+
const { setupCodex } = require('./codex/setup');
|
|
13
|
+
const { icon } = require('./output-icons');
|
|
14
|
+
const { detectDependencies, detectMainDirs, detectProjectMetadata, detectScripts, generateMermaid, getFrameworkInstructions } = require('./setup/analysis');
|
|
14
15
|
const { applyTemplateResults, collectFailedSetupTemplates, mergeGeneratedHookSettings, snapshotSettingsBeforeSetup } = require('./setup/runtime');
|
|
15
16
|
|
|
16
17
|
// ============================================================
|
|
@@ -617,7 +618,7 @@ async function setup(options) {
|
|
|
617
618
|
preservedFiles = settingsMerge.preservedFiles;
|
|
618
619
|
log('');
|
|
619
620
|
if (created === 0 && skipped > 0) {
|
|
620
|
-
log(
|
|
621
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Your project is already well configured!`);
|
|
621
622
|
log(` \x1b[2m ${skipped} files already exist and were preserved.\x1b[0m`);
|
|
622
623
|
log(' \x1b[2m We never overwrite your existing config — your setup is kept.\x1b[0m');
|
|
623
624
|
} else if (created > 0) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { buildFinding, SHALLOW_RISK_BANNER, SHALLOW_RISK_BANNER_LINES } = require('./shared');
|
|
4
|
+
|
|
5
|
+
const patterns = [
|
|
6
|
+
require('./patterns/agent-config-missing-file'),
|
|
7
|
+
require('./patterns/agent-config-stack-contradiction'),
|
|
8
|
+
require('./patterns/agent-config-cross-platform-drift'),
|
|
9
|
+
require('./patterns/mcp-server-no-allowlist'),
|
|
10
|
+
require('./patterns/hook-script-missing'),
|
|
11
|
+
require('./patterns/agent-config-secret-literal'),
|
|
12
|
+
require('./patterns/agent-config-deprecated-keys'),
|
|
13
|
+
require('./patterns/agent-config-dangerous-autoapprove'),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function runShallowRisk(ctx) {
|
|
17
|
+
if (!ctx || process.env.NERVIQ_SHALLOW_RISK === 'off') {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const findings = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
|
|
24
|
+
for (const pattern of patterns) {
|
|
25
|
+
let emitted = [];
|
|
26
|
+
try {
|
|
27
|
+
const next = pattern.run(ctx);
|
|
28
|
+
emitted = Array.isArray(next) ? next : [];
|
|
29
|
+
} catch {
|
|
30
|
+
emitted = [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const finding of emitted) {
|
|
34
|
+
const normalized = buildFinding(pattern, ctx, finding || {});
|
|
35
|
+
const dedupeKey = [
|
|
36
|
+
normalized.key,
|
|
37
|
+
normalized.file || '',
|
|
38
|
+
normalized.line || '',
|
|
39
|
+
normalized.fix || '',
|
|
40
|
+
].join('|');
|
|
41
|
+
|
|
42
|
+
if (seen.has(dedupeKey)) continue;
|
|
43
|
+
seen.add(dedupeKey);
|
|
44
|
+
findings.push(normalized);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return findings;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
patterns,
|
|
53
|
+
runShallowRisk,
|
|
54
|
+
SHALLOW_RISK_BANNER,
|
|
55
|
+
SHALLOW_RISK_BANNER_LINES,
|
|
56
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SHALLOW_RISK_DOC_URL,
|
|
5
|
+
collectStackClaims,
|
|
6
|
+
} = require('../shared');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
key: 'agent-config-cross-platform-drift',
|
|
10
|
+
name: 'Cross-platform stack drift detected',
|
|
11
|
+
severity: 'high',
|
|
12
|
+
layer: 'shallow-risk',
|
|
13
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
14
|
+
run(ctx) {
|
|
15
|
+
const claims = collectStackClaims(ctx).filter((claim) => claim.platform !== 'agent');
|
|
16
|
+
if (claims.length < 2) return [];
|
|
17
|
+
|
|
18
|
+
const byPlatform = new Map();
|
|
19
|
+
for (const claim of claims) {
|
|
20
|
+
const bucket = byPlatform.get(claim.platform) || [];
|
|
21
|
+
bucket.push(claim);
|
|
22
|
+
byPlatform.set(claim.platform, bucket);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const representatives = [];
|
|
26
|
+
for (const bucket of byPlatform.values()) {
|
|
27
|
+
const uniqueKeys = [...new Set(bucket.map((claim) => claim.key))];
|
|
28
|
+
if (uniqueKeys.length !== 1) continue;
|
|
29
|
+
representatives.push(bucket[0]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
representatives.sort((left, right) => left.file.localeCompare(right.file));
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < representatives.length; index++) {
|
|
35
|
+
for (let inner = index + 1; inner < representatives.length; inner++) {
|
|
36
|
+
const first = representatives[index];
|
|
37
|
+
const second = representatives[inner];
|
|
38
|
+
if (first.key === second.key) continue;
|
|
39
|
+
|
|
40
|
+
return [{
|
|
41
|
+
file: first.file,
|
|
42
|
+
line: first.line,
|
|
43
|
+
fix: `Drift detected: ${first.file} declares "${first.label}" while ${second.file} declares "${second.label}". Align the shared primary-language guidance or document an intentional platform-specific override.`,
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return [];
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SHALLOW_RISK_DOC_URL, escapeRegExp } = require('../shared');
|
|
4
|
+
|
|
5
|
+
const DANGEROUS_ALLOW_PATTERNS = [
|
|
6
|
+
/\brm\b[\s\S]{0,40}-r/i,
|
|
7
|
+
/\bgit\s+push\s+--force\b/i,
|
|
8
|
+
/\bdrop\s+(?:database|table)\b/i,
|
|
9
|
+
/\btruncate\s+table\b/i,
|
|
10
|
+
/\bdelete\s+from\b/i,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function isDangerousAllowRule(rule) {
|
|
14
|
+
if (typeof rule !== 'string') return false;
|
|
15
|
+
if (/\bdelete\s+from\b/i.test(rule)) {
|
|
16
|
+
return !/\bwhere\b/i.test(rule) || /\bwhere\s*1\s*=\s*1\b/i.test(rule);
|
|
17
|
+
}
|
|
18
|
+
return DANGEROUS_ALLOW_PATTERNS.some((pattern) => {
|
|
19
|
+
pattern.lastIndex = 0;
|
|
20
|
+
return pattern.test(rule);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
key: 'agent-config-dangerous-autoapprove',
|
|
26
|
+
name: 'Agent config auto-approves destructive commands',
|
|
27
|
+
severity: 'critical',
|
|
28
|
+
layer: 'shallow-risk',
|
|
29
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
30
|
+
run(ctx) {
|
|
31
|
+
const file = '.claude/settings.json';
|
|
32
|
+
const config = ctx.jsonFile(file);
|
|
33
|
+
const allowRules = config && config.permissions && Array.isArray(config.permissions.allow)
|
|
34
|
+
? config.permissions.allow
|
|
35
|
+
: [];
|
|
36
|
+
if (allowRules.length === 0) return [];
|
|
37
|
+
|
|
38
|
+
return allowRules
|
|
39
|
+
.filter(isDangerousAllowRule)
|
|
40
|
+
.map((rule) => ({
|
|
41
|
+
file,
|
|
42
|
+
line: ctx.lineNumber(file, new RegExp(escapeRegExp(rule))) || 1,
|
|
43
|
+
fix: `${file} pre-approves the destructive rule \`${rule}\`. Remove it from the allow-list so destructive commands always require explicit review.`,
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
};
|