@neurcode-ai/cli 0.9.26 → 0.9.28

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.
Files changed (59) hide show
  1. package/dist/commands/allow.d.ts.map +1 -1
  2. package/dist/commands/allow.js +5 -19
  3. package/dist/commands/allow.js.map +1 -1
  4. package/dist/commands/apply.d.ts +1 -0
  5. package/dist/commands/apply.d.ts.map +1 -1
  6. package/dist/commands/apply.js +105 -46
  7. package/dist/commands/apply.js.map +1 -1
  8. package/dist/commands/ask.d.ts.map +1 -1
  9. package/dist/commands/ask.js +1849 -1783
  10. package/dist/commands/ask.js.map +1 -1
  11. package/dist/commands/init.d.ts +2 -0
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +83 -24
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/plan.d.ts +4 -0
  16. package/dist/commands/plan.d.ts.map +1 -1
  17. package/dist/commands/plan.js +344 -48
  18. package/dist/commands/plan.js.map +1 -1
  19. package/dist/commands/policy.d.ts.map +1 -1
  20. package/dist/commands/policy.js +629 -0
  21. package/dist/commands/policy.js.map +1 -1
  22. package/dist/commands/prompt.d.ts +7 -1
  23. package/dist/commands/prompt.d.ts.map +1 -1
  24. package/dist/commands/prompt.js +106 -25
  25. package/dist/commands/prompt.js.map +1 -1
  26. package/dist/commands/ship.d.ts +32 -0
  27. package/dist/commands/ship.d.ts.map +1 -1
  28. package/dist/commands/ship.js +1404 -75
  29. package/dist/commands/ship.js.map +1 -1
  30. package/dist/commands/verify.d.ts +6 -0
  31. package/dist/commands/verify.d.ts.map +1 -1
  32. package/dist/commands/verify.js +527 -102
  33. package/dist/commands/verify.js.map +1 -1
  34. package/dist/index.js +89 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/utils/custom-policy-rules.d.ts +21 -0
  37. package/dist/utils/custom-policy-rules.d.ts.map +1 -0
  38. package/dist/utils/custom-policy-rules.js +71 -0
  39. package/dist/utils/custom-policy-rules.js.map +1 -0
  40. package/dist/utils/plan-cache.d.ts.map +1 -1
  41. package/dist/utils/plan-cache.js +4 -0
  42. package/dist/utils/plan-cache.js.map +1 -1
  43. package/dist/utils/policy-audit.d.ts +29 -0
  44. package/dist/utils/policy-audit.d.ts.map +1 -0
  45. package/dist/utils/policy-audit.js +208 -0
  46. package/dist/utils/policy-audit.js.map +1 -0
  47. package/dist/utils/policy-exceptions.d.ts +96 -0
  48. package/dist/utils/policy-exceptions.d.ts.map +1 -0
  49. package/dist/utils/policy-exceptions.js +389 -0
  50. package/dist/utils/policy-exceptions.js.map +1 -0
  51. package/dist/utils/policy-governance.d.ts +24 -0
  52. package/dist/utils/policy-governance.d.ts.map +1 -0
  53. package/dist/utils/policy-governance.js +124 -0
  54. package/dist/utils/policy-governance.js.map +1 -0
  55. package/dist/utils/policy-packs.d.ts +72 -1
  56. package/dist/utils/policy-packs.d.ts.map +1 -1
  57. package/dist/utils/policy-packs.js +285 -0
  58. package/dist/utils/policy-packs.js.map +1 -1
  59. package/package.json +1 -1
@@ -54,6 +54,10 @@ const ignore_1 = require("../utils/ignore");
54
54
  const project_root_1 = require("../utils/project-root");
55
55
  const brain_context_1 = require("../utils/brain-context");
56
56
  const policy_packs_1 = require("../utils/policy-packs");
57
+ const custom_policy_rules_1 = require("../utils/custom-policy-rules");
58
+ const policy_exceptions_1 = require("../utils/policy-exceptions");
59
+ const policy_governance_1 = require("../utils/policy-governance");
60
+ const policy_audit_1 = require("../utils/policy-audit");
57
61
  // Import chalk with fallback
58
62
  let chalk;
59
63
  try {
@@ -78,21 +82,21 @@ catch {
78
82
  * Check if a file path should be excluded from verification analysis
79
83
  * Excludes internal/system files that should not count towards plan adherence
80
84
  */
81
- const IGNORED_METADATA_FILES = new Set(['neurcode.config.json']);
82
- const IGNORED_DIRECTORIES = ['.neurcode/'];
85
+ const IGNORED_METADATA_FILE_PATTERN = /(^|\/)neurcode\.config\.json$/i;
86
+ const IGNORED_DIRECTORIES = ['.git/', 'node_modules/'];
83
87
  function isExcludedFile(filePath) {
84
88
  // Normalize path separators (handle both / and \)
85
89
  const normalizedPath = filePath.replace(/\\/g, '/');
86
90
  // Ignore specific metadata files (these should never be scope/policy violations)
87
- if (IGNORED_METADATA_FILES.has(normalizedPath)) {
91
+ if (IGNORED_METADATA_FILE_PATTERN.test(normalizedPath)) {
92
+ return true;
93
+ }
94
+ // Ignore .neurcode internals at any nesting level (monorepo-safe)
95
+ if (/(^|\/)\.neurcode\//.test(normalizedPath)) {
88
96
  return true;
89
97
  }
90
98
  // Check if path starts with any excluded prefix
91
- const excludedPrefixes = [
92
- ...IGNORED_DIRECTORIES,
93
- '.git/',
94
- 'node_modules/',
95
- ];
99
+ const excludedPrefixes = [...IGNORED_DIRECTORIES];
96
100
  // Check prefixes
97
101
  for (const prefix of excludedPrefixes) {
98
102
  if (normalizedPath.startsWith(prefix)) {
@@ -203,76 +207,17 @@ function getRuntimeIgnoreSetFromEnv() {
203
207
  .map((item) => toUnixPath(item.trim()))
204
208
  .filter(Boolean));
205
209
  }
206
- /**
207
- * Map a dashboard custom policy (natural language) to a policy-engine Rule.
208
- * Supports "No console.log", "No debugger", and generic line patterns.
209
- */
210
- function customPolicyToRule(p) {
211
- const sev = p.severity === 'high' ? 'block' : 'warn';
212
- const text = (p.rule_text || '').trim().toLowerCase();
213
- if (!text)
214
- return null;
215
- // Known patterns -> suspicious-keywords (checks added lines for these strings)
216
- if (/console\.log|no\s+console\.log|do not use console\.log|ban console\.log/.test(text)) {
217
- return {
218
- id: `custom-${p.id}`,
219
- name: 'No console.log',
220
- description: p.rule_text,
221
- enabled: true,
222
- severity: sev,
223
- type: 'suspicious-keywords',
224
- keywords: ['console.log'],
225
- };
226
- }
227
- if (/debugger|no\s+debugger|do not use debugger/.test(text)) {
228
- return {
229
- id: `custom-${p.id}`,
230
- name: 'No debugger',
231
- description: p.rule_text,
232
- enabled: true,
233
- severity: sev,
234
- type: 'suspicious-keywords',
235
- keywords: ['debugger'],
236
- };
237
- }
238
- if (/eval\s*\(|no\s+eval|do not use eval/.test(text)) {
239
- return {
240
- id: `custom-${p.id}`,
241
- name: 'No eval',
242
- description: p.rule_text,
243
- enabled: true,
244
- severity: sev,
245
- type: 'suspicious-keywords',
246
- keywords: ['eval('],
247
- };
248
- }
249
- // Fallback: line-pattern on added lines using a safe regex from the rule text
250
- // Use first quoted phrase or first alphanumeric phrase as pattern
251
- const quoted = /['"`]([^'"`]+)['"`]/.exec(p.rule_text);
252
- const phrase = quoted?.[1] ?? p.rule_text.replace(/^(no|don't|do not use|ban|avoid)\s+/i, '').trim().slice(0, 80);
253
- if (!phrase)
254
- return null;
255
- const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
256
- return {
257
- id: `custom-${p.id}`,
258
- name: `Custom: ${p.rule_text.slice(0, 50)}`,
259
- description: p.rule_text,
260
- enabled: true,
261
- severity: sev,
262
- type: 'line-pattern',
263
- pattern: escaped,
264
- matchType: 'added',
265
- };
266
- }
267
210
  async function buildEffectivePolicyRules(client, projectRoot, useDashboardPolicies) {
268
211
  const defaultPolicy = (0, policy_engine_1.createDefaultPolicy)();
269
212
  const customRules = [];
213
+ const customPolicies = [];
270
214
  const installedPack = (0, policy_packs_1.getInstalledPolicyPackRules)(projectRoot);
271
215
  const policyPackRules = installedPack?.rules ? [...installedPack.rules] : [];
272
216
  if (useDashboardPolicies) {
273
- const customPolicies = await client.getActiveCustomPolicies();
274
- for (const p of customPolicies) {
275
- const r = customPolicyToRule(p);
217
+ const loadedCustomPolicies = await client.getActiveCustomPolicies();
218
+ for (const p of loadedCustomPolicies) {
219
+ customPolicies.push(p);
220
+ const r = (0, custom_policy_rules_1.customPolicyToRule)(p);
276
221
  if (r) {
277
222
  customRules.push(r);
278
223
  }
@@ -281,8 +226,68 @@ async function buildEffectivePolicyRules(client, projectRoot, useDashboardPolici
281
226
  return {
282
227
  allRules: [...defaultPolicy.rules, ...policyPackRules, ...customRules],
283
228
  customRules,
229
+ customPolicies,
284
230
  policyPackRules,
285
231
  policyPack: installedPack,
232
+ includeDashboardPolicies: useDashboardPolicies,
233
+ };
234
+ }
235
+ const POLICY_AUDIT_FILE = 'neurcode.policy.audit.log.jsonl';
236
+ function isEnabledFlag(value) {
237
+ if (!value)
238
+ return false;
239
+ const normalized = value.trim().toLowerCase();
240
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
241
+ }
242
+ function policyLockMismatchMessage(mismatches) {
243
+ if (mismatches.length === 0) {
244
+ return 'Policy lock baseline check failed';
245
+ }
246
+ return `Policy lock mismatch: ${mismatches.map((item) => `[${item.code}] ${item.message}`).join('; ')}`;
247
+ }
248
+ function toPolicyLockViolations(mismatches) {
249
+ return mismatches.map((item) => ({
250
+ file: 'neurcode.policy.lock.json',
251
+ rule: `policy_lock:${item.code.toLowerCase()}`,
252
+ severity: 'block',
253
+ message: item.message,
254
+ }));
255
+ }
256
+ function resolvePolicyDecisionFromViolations(violations) {
257
+ let hasWarn = false;
258
+ for (const violation of violations) {
259
+ const severity = String(violation.severity || '').toLowerCase();
260
+ if (severity === 'block') {
261
+ return 'block';
262
+ }
263
+ if (severity === 'warn') {
264
+ hasWarn = true;
265
+ }
266
+ }
267
+ return hasWarn ? 'warn' : 'allow';
268
+ }
269
+ function explainExceptionEligibilityReason(reason) {
270
+ switch (reason) {
271
+ case 'approval_required':
272
+ return 'exception exists but approvals are required';
273
+ case 'insufficient_approvals':
274
+ return 'exception exists but approval threshold is not met';
275
+ case 'self_approval_only':
276
+ return 'exception only has requester self-approval';
277
+ case 'approver_not_allowed':
278
+ return 'exception approvals are from non-allowlisted approvers';
279
+ default:
280
+ return 'exception is inactive or expired';
281
+ }
282
+ }
283
+ function resolveAuditIntegrityStatus(requireIntegrity, auditIntegrity) {
284
+ const issues = [...auditIntegrity.issues];
285
+ if (requireIntegrity && auditIntegrity.count === 0) {
286
+ issues.push('audit chain has no events; commit neurcode.policy.audit.log.jsonl');
287
+ }
288
+ return {
289
+ valid: issues.length === 0,
290
+ issues,
286
291
  };
287
292
  }
288
293
  /**
@@ -295,20 +300,108 @@ async function executePolicyOnlyMode(options, diffFiles, ignoreFilter, projectRo
295
300
  }
296
301
  let policyViolations = [];
297
302
  let policyDecision = 'allow';
303
+ const requirePolicyLock = options.requirePolicyLock === true || isEnabledFlag(process.env.NEURCODE_VERIFY_REQUIRE_POLICY_LOCK);
304
+ const skipPolicyLock = options.skipPolicyLock === true || isEnabledFlag(process.env.NEURCODE_VERIFY_SKIP_POLICY_LOCK);
305
+ const lockRead = (0, policy_packs_1.readPolicyLockFile)(projectRoot);
306
+ const includeDashboardPolicies = lockRead.lock
307
+ ? lockRead.lock.customPolicies.mode === 'dashboard'
308
+ : Boolean(config.apiKey);
309
+ let effectiveRulesLoadError = null;
298
310
  let effectiveRules = {
299
311
  allRules: (0, policy_engine_1.createDefaultPolicy)().rules,
300
312
  customRules: [],
313
+ customPolicies: [],
301
314
  policyPackRules: [],
302
315
  policyPack: null,
316
+ includeDashboardPolicies,
303
317
  };
304
318
  try {
305
- effectiveRules = await buildEffectivePolicyRules(client, projectRoot, Boolean(config.apiKey));
319
+ effectiveRules = await buildEffectivePolicyRules(client, projectRoot, includeDashboardPolicies);
306
320
  }
307
321
  catch (error) {
322
+ effectiveRulesLoadError = error instanceof Error ? error.message : 'Unknown error';
323
+ const installedPack = (0, policy_packs_1.getInstalledPolicyPackRules)(projectRoot);
324
+ const fallbackPolicyPackRules = installedPack?.rules ? [...installedPack.rules] : [];
325
+ effectiveRules = {
326
+ allRules: [...(0, policy_engine_1.createDefaultPolicy)().rules, ...fallbackPolicyPackRules],
327
+ customRules: [],
328
+ customPolicies: [],
329
+ policyPackRules: fallbackPolicyPackRules,
330
+ policyPack: installedPack,
331
+ includeDashboardPolicies,
332
+ };
308
333
  if (!options.json) {
309
334
  console.log(chalk.dim(' Could not load dashboard custom policies, using local/default policy rules only'));
310
335
  }
311
336
  }
337
+ let policyLockEvaluation = {
338
+ enforced: false,
339
+ matched: true,
340
+ lockPresent: lockRead.lock !== null,
341
+ lockPath: lockRead.path,
342
+ mismatches: [],
343
+ };
344
+ if (!skipPolicyLock) {
345
+ const currentSnapshot = (0, policy_packs_1.buildPolicyStateSnapshot)({
346
+ policyPack: effectiveRules.policyPack,
347
+ policyPackRules: effectiveRules.policyPackRules,
348
+ customPolicies: effectiveRules.customPolicies,
349
+ customRules: effectiveRules.customRules,
350
+ includeDashboardPolicies: effectiveRules.includeDashboardPolicies,
351
+ });
352
+ const lockValidation = (0, policy_packs_1.evaluatePolicyLock)(projectRoot, currentSnapshot, {
353
+ requireLock: requirePolicyLock,
354
+ });
355
+ policyLockEvaluation = {
356
+ enforced: lockValidation.enforced,
357
+ matched: lockValidation.matched,
358
+ lockPresent: lockValidation.lockPresent,
359
+ lockPath: lockValidation.lockPath,
360
+ mismatches: [...lockValidation.mismatches],
361
+ };
362
+ if (effectiveRulesLoadError && includeDashboardPolicies) {
363
+ policyLockEvaluation.mismatches.unshift({
364
+ code: 'POLICY_LOCK_CUSTOM_POLICIES_MISMATCH',
365
+ message: `Failed to load dashboard custom policies: ${effectiveRulesLoadError}`,
366
+ });
367
+ policyLockEvaluation.matched = false;
368
+ }
369
+ }
370
+ if (policyLockEvaluation.enforced && !policyLockEvaluation.matched) {
371
+ const message = policyLockMismatchMessage(policyLockEvaluation.mismatches);
372
+ if (options.json) {
373
+ console.log(JSON.stringify({
374
+ grade: 'F',
375
+ score: 0,
376
+ verdict: 'FAIL',
377
+ violations: toPolicyLockViolations(policyLockEvaluation.mismatches),
378
+ message,
379
+ scopeGuardPassed: true,
380
+ bloatCount: 0,
381
+ bloatFiles: [],
382
+ plannedFilesModified: 0,
383
+ totalPlannedFiles: 0,
384
+ adherenceScore: 0,
385
+ mode: 'policy_only',
386
+ policyOnly: true,
387
+ policyLock: {
388
+ enforced: true,
389
+ matched: false,
390
+ path: policyLockEvaluation.lockPath,
391
+ mismatches: policyLockEvaluation.mismatches,
392
+ },
393
+ }, null, 2));
394
+ }
395
+ else {
396
+ console.log(chalk.red('❌ Policy lock baseline mismatch.'));
397
+ console.log(chalk.dim(` Lock file: ${policyLockEvaluation.lockPath}`));
398
+ policyLockEvaluation.mismatches.forEach((item) => {
399
+ console.log(chalk.red(` • [${item.code}] ${item.message}`));
400
+ });
401
+ console.log(chalk.dim('\n If drift is intentional, regenerate baseline with `neurcode policy lock`.\n'));
402
+ }
403
+ return 2;
404
+ }
312
405
  if (!options.json && effectiveRules.customRules.length > 0) {
313
406
  console.log(chalk.dim(` Evaluating ${effectiveRules.customRules.length} custom policy rule(s) from dashboard`));
314
407
  }
@@ -322,7 +415,39 @@ async function executePolicyOnlyMode(options, diffFiles, ignoreFilter, projectRo
322
415
  const policyResult = (0, policy_engine_1.evaluateRules)(diffFilesForPolicy, effectiveRules.allRules);
323
416
  policyViolations = (policyResult.violations || []);
324
417
  policyViolations = policyViolations.filter((v) => !ignoreFilter(v.file));
325
- policyDecision = policyViolations.length > 0 ? policyResult.decision : 'allow';
418
+ const governance = (0, policy_governance_1.readPolicyGovernanceConfig)(projectRoot);
419
+ const auditIntegrity = (0, policy_audit_1.verifyPolicyAuditIntegrity)(projectRoot);
420
+ const auditIntegrityStatus = resolveAuditIntegrityStatus(governance.audit.requireIntegrity, auditIntegrity);
421
+ const configuredPolicyExceptions = (0, policy_exceptions_1.readPolicyExceptions)(projectRoot);
422
+ const exceptionDecision = (0, policy_exceptions_1.applyPolicyExceptions)(policyViolations, configuredPolicyExceptions, {
423
+ requireApproval: governance.exceptionApprovals.required,
424
+ minApprovals: governance.exceptionApprovals.minApprovals,
425
+ disallowSelfApproval: governance.exceptionApprovals.disallowSelfApproval,
426
+ allowedApprovers: governance.exceptionApprovals.allowedApprovers,
427
+ });
428
+ const suppressedViolations = exceptionDecision.suppressedViolations.filter((item) => !ignoreFilter(item.file));
429
+ const blockedViolations = exceptionDecision.blockedViolations
430
+ .filter((item) => !ignoreFilter(item.file))
431
+ .map((item) => ({
432
+ file: item.file,
433
+ rule: item.rule,
434
+ severity: 'block',
435
+ message: `Exception ${item.exceptionId} cannot be applied: ${explainExceptionEligibilityReason(item.eligibilityReason)}`,
436
+ ...(item.line != null ? { line: item.line } : {}),
437
+ }));
438
+ policyViolations = [
439
+ ...exceptionDecision.remainingViolations.filter((item) => !ignoreFilter(item.file)),
440
+ ...blockedViolations,
441
+ ];
442
+ if (governance.audit.requireIntegrity && !auditIntegrityStatus.valid) {
443
+ policyViolations.push({
444
+ file: POLICY_AUDIT_FILE,
445
+ rule: 'policy_audit_integrity',
446
+ severity: 'block',
447
+ message: `Policy audit chain is invalid: ${auditIntegrityStatus.issues.join('; ') || 'unknown issue'}`,
448
+ });
449
+ }
450
+ policyDecision = resolvePolicyDecisionFromViolations(policyViolations);
326
451
  const effectiveVerdict = policyDecision === 'block' ? 'FAIL' : policyDecision === 'warn' ? 'WARN' : 'PASS';
327
452
  const grade = effectiveVerdict === 'PASS' ? 'A' : effectiveVerdict === 'WARN' ? 'C' : 'F';
328
453
  const score = effectiveVerdict === 'PASS' ? 100 : effectiveVerdict === 'WARN' ? 50 : 0;
@@ -338,6 +463,42 @@ async function executePolicyOnlyMode(options, diffFiles, ignoreFilter, projectRo
338
463
  : policyViolations.length > 0
339
464
  ? `Policy violations: ${policyViolations.map((v) => `${v.file}: ${v.message || v.rule}`).join('; ')}`
340
465
  : 'Policy check completed';
466
+ const policyExceptionsSummary = {
467
+ configured: configuredPolicyExceptions.length,
468
+ active: exceptionDecision.activeExceptions.length,
469
+ usable: exceptionDecision.usableExceptions.length,
470
+ matched: exceptionDecision.matchedExceptionIds.length,
471
+ suppressed: suppressedViolations.length,
472
+ blocked: blockedViolations.length,
473
+ matchedExceptionIds: exceptionDecision.matchedExceptionIds,
474
+ suppressedViolations: suppressedViolations.map((item) => ({
475
+ file: item.file,
476
+ rule: item.rule,
477
+ severity: item.severity,
478
+ message: item.message,
479
+ exceptionId: item.exceptionId,
480
+ reason: item.reason,
481
+ expiresAt: item.expiresAt,
482
+ ...(item.line != null ? { startLine: item.line } : {}),
483
+ })),
484
+ blockedViolations: blockedViolations.map((item) => ({
485
+ file: item.file,
486
+ rule: item.rule,
487
+ severity: item.severity,
488
+ message: item.message,
489
+ ...(item.line != null ? { startLine: item.line } : {}),
490
+ })),
491
+ };
492
+ const policyGovernanceSummary = {
493
+ exceptionApprovals: governance.exceptionApprovals,
494
+ audit: {
495
+ requireIntegrity: governance.audit.requireIntegrity,
496
+ valid: auditIntegrityStatus.valid,
497
+ issues: auditIntegrityStatus.issues,
498
+ lastHash: auditIntegrity.lastHash,
499
+ eventCount: auditIntegrity.count,
500
+ },
501
+ };
341
502
  if (options.json) {
342
503
  console.log(JSON.stringify({
343
504
  grade,
@@ -351,7 +512,16 @@ async function executePolicyOnlyMode(options, diffFiles, ignoreFilter, projectRo
351
512
  plannedFilesModified: 0,
352
513
  totalPlannedFiles: 0,
353
514
  adherenceScore: score,
515
+ mode: 'policy_only',
354
516
  policyOnly: true,
517
+ policyLock: {
518
+ enforced: policyLockEvaluation.enforced,
519
+ matched: policyLockEvaluation.matched,
520
+ path: policyLockEvaluation.lockPath,
521
+ mismatches: policyLockEvaluation.mismatches,
522
+ },
523
+ policyExceptions: policyExceptionsSummary,
524
+ policyGovernance: policyGovernanceSummary,
355
525
  ...(effectiveRules.policyPack
356
526
  ? {
357
527
  policyPack: {
@@ -374,6 +544,15 @@ async function executePolicyOnlyMode(options, diffFiles, ignoreFilter, projectRo
374
544
  console.log(chalk.red(` • ${v.file}: ${v.message || v.rule}`));
375
545
  });
376
546
  }
547
+ if (policyExceptionsSummary.suppressed > 0) {
548
+ console.log(chalk.yellow(` Policy exceptions applied: ${policyExceptionsSummary.suppressed}`));
549
+ }
550
+ if (policyExceptionsSummary.blocked > 0) {
551
+ console.log(chalk.red(` Policy exceptions blocked by approval governance: ${policyExceptionsSummary.blocked}`));
552
+ }
553
+ if (governance.audit.requireIntegrity && !auditIntegrityStatus.valid) {
554
+ console.log(chalk.red(' Policy audit integrity check failed'));
555
+ }
377
556
  console.log(chalk.dim(`\n${message}`));
378
557
  }
379
558
  return effectiveVerdict === 'FAIL' ? 2 : effectiveVerdict === 'WARN' ? 1 : 0;
@@ -561,6 +740,7 @@ async function verifyCommand(options) {
561
740
  recordVerifyEvent(verdict, `policy_only=true;exit=${exitCode}`, changedFiles);
562
741
  process.exit(exitCode);
563
742
  }
743
+ const requirePlan = options.requirePlan === true || process.env.NEURCODE_VERIFY_REQUIRE_PLAN === '1';
564
744
  // Get planId: Priority 1: options flag, Priority 2: state file (.neurcode/config.json), Priority 3: legacy config
565
745
  let planId = options.planId;
566
746
  if (!planId) {
@@ -593,8 +773,35 @@ async function verifyCommand(options) {
593
773
  }
594
774
  }
595
775
  }
596
- // If no planId found, fall back to General Governance (Policy Only) mode
776
+ // If no planId found, either enforce strict requirement or fall back to policy-only mode.
597
777
  if (!planId) {
778
+ if (requirePlan) {
779
+ const changedFiles = diffFiles.map((f) => f.path);
780
+ recordVerifyEvent('FAIL', 'missing_plan_id;require_plan=true', changedFiles);
781
+ if (options.json) {
782
+ console.log(JSON.stringify({
783
+ grade: 'F',
784
+ score: 0,
785
+ verdict: 'FAIL',
786
+ violations: [],
787
+ adherenceScore: 0,
788
+ bloatCount: 0,
789
+ bloatFiles: [],
790
+ plannedFilesModified: 0,
791
+ totalPlannedFiles: 0,
792
+ message: 'Plan ID is required in strict mode. Run "neurcode plan" first or pass --plan-id.',
793
+ scopeGuardPassed: false,
794
+ mode: 'plan_required',
795
+ policyOnly: false,
796
+ }, null, 2));
797
+ }
798
+ else {
799
+ console.log(chalk.red('❌ Plan ID is required in strict mode.'));
800
+ console.log(chalk.dim(' Run "neurcode plan" first or pass --plan-id <id>.'));
801
+ console.log(chalk.dim(' Use --policy-only only when intentionally running general governance checks.'));
802
+ }
803
+ process.exit(1);
804
+ }
598
805
  if (!options.json) {
599
806
  console.log(chalk.yellow('⚠️ No Plan ID found. Falling back to General Governance (Policy Only).'));
600
807
  }
@@ -692,6 +899,8 @@ async function verifyCommand(options) {
692
899
  totalPlannedFiles: planFiles.length,
693
900
  message: `Scope violation: ${filteredViolations.length} file(s) modified outside the plan`,
694
901
  scopeGuardPassed: false,
902
+ mode: 'plan_enforced',
903
+ policyOnly: false,
695
904
  };
696
905
  // CRITICAL: Print JSON first, then exit
697
906
  console.log(JSON.stringify(jsonOutput, null, 2));
@@ -731,7 +940,113 @@ async function verifyCommand(options) {
731
940
  console.log('');
732
941
  }
733
942
  }
943
+ const requirePolicyLock = options.requirePolicyLock === true || isEnabledFlag(process.env.NEURCODE_VERIFY_REQUIRE_POLICY_LOCK);
944
+ const skipPolicyLock = options.skipPolicyLock === true || isEnabledFlag(process.env.NEURCODE_VERIFY_SKIP_POLICY_LOCK);
945
+ const lockRead = (0, policy_packs_1.readPolicyLockFile)(projectRoot);
946
+ const useDashboardPolicies = lockRead.lock
947
+ ? lockRead.lock.customPolicies.mode === 'dashboard'
948
+ : Boolean(config.apiKey);
949
+ let effectiveRulesLoadError = null;
950
+ let effectiveRules = {
951
+ allRules: (0, policy_engine_1.createDefaultPolicy)().rules,
952
+ customRules: [],
953
+ customPolicies: [],
954
+ policyPackRules: [],
955
+ policyPack: null,
956
+ includeDashboardPolicies: useDashboardPolicies,
957
+ };
958
+ try {
959
+ effectiveRules = await buildEffectivePolicyRules(client, projectRoot, useDashboardPolicies);
960
+ }
961
+ catch (error) {
962
+ effectiveRulesLoadError = error instanceof Error ? error.message : 'Unknown error';
963
+ const installedPack = (0, policy_packs_1.getInstalledPolicyPackRules)(projectRoot);
964
+ const fallbackPolicyPackRules = installedPack?.rules ? [...installedPack.rules] : [];
965
+ effectiveRules = {
966
+ allRules: [...(0, policy_engine_1.createDefaultPolicy)().rules, ...fallbackPolicyPackRules],
967
+ customRules: [],
968
+ customPolicies: [],
969
+ policyPackRules: fallbackPolicyPackRules,
970
+ policyPack: installedPack,
971
+ includeDashboardPolicies: useDashboardPolicies,
972
+ };
973
+ if (!options.json) {
974
+ console.log(chalk.dim(' Could not load dashboard custom policies, continuing with local/default rules only'));
975
+ }
976
+ }
977
+ let policyLockEvaluation = {
978
+ enforced: false,
979
+ matched: true,
980
+ lockPresent: lockRead.lock !== null,
981
+ lockPath: lockRead.path,
982
+ mismatches: [],
983
+ };
984
+ if (!skipPolicyLock) {
985
+ const currentSnapshot = (0, policy_packs_1.buildPolicyStateSnapshot)({
986
+ policyPack: effectiveRules.policyPack,
987
+ policyPackRules: effectiveRules.policyPackRules,
988
+ customPolicies: effectiveRules.customPolicies,
989
+ customRules: effectiveRules.customRules,
990
+ includeDashboardPolicies: effectiveRules.includeDashboardPolicies,
991
+ });
992
+ const lockValidation = (0, policy_packs_1.evaluatePolicyLock)(projectRoot, currentSnapshot, {
993
+ requireLock: requirePolicyLock,
994
+ });
995
+ policyLockEvaluation = {
996
+ enforced: lockValidation.enforced,
997
+ matched: lockValidation.matched,
998
+ lockPresent: lockValidation.lockPresent,
999
+ lockPath: lockValidation.lockPath,
1000
+ mismatches: [...lockValidation.mismatches],
1001
+ };
1002
+ if (effectiveRulesLoadError && useDashboardPolicies) {
1003
+ policyLockEvaluation.mismatches.unshift({
1004
+ code: 'POLICY_LOCK_CUSTOM_POLICIES_MISMATCH',
1005
+ message: `Failed to load dashboard custom policies: ${effectiveRulesLoadError}`,
1006
+ });
1007
+ policyLockEvaluation.matched = false;
1008
+ }
1009
+ if (policyLockEvaluation.enforced && !policyLockEvaluation.matched) {
1010
+ const message = policyLockMismatchMessage(policyLockEvaluation.mismatches);
1011
+ recordVerifyEvent('FAIL', 'policy_lock_mismatch', diffFiles.map((f) => f.path), finalPlanId);
1012
+ if (options.json) {
1013
+ console.log(JSON.stringify({
1014
+ grade: 'F',
1015
+ score: 0,
1016
+ verdict: 'FAIL',
1017
+ violations: toPolicyLockViolations(policyLockEvaluation.mismatches),
1018
+ adherenceScore: 0,
1019
+ bloatCount: 0,
1020
+ bloatFiles: [],
1021
+ plannedFilesModified: 0,
1022
+ totalPlannedFiles: 0,
1023
+ message,
1024
+ scopeGuardPassed,
1025
+ mode: 'plan_enforced',
1026
+ policyOnly: false,
1027
+ policyLock: {
1028
+ enforced: true,
1029
+ matched: false,
1030
+ path: policyLockEvaluation.lockPath,
1031
+ mismatches: policyLockEvaluation.mismatches,
1032
+ },
1033
+ }, null, 2));
1034
+ }
1035
+ else {
1036
+ console.log(chalk.red('\n❌ Policy lock baseline mismatch'));
1037
+ console.log(chalk.dim(` Lock file: ${policyLockEvaluation.lockPath}`));
1038
+ policyLockEvaluation.mismatches.forEach((item) => {
1039
+ console.log(chalk.red(` • [${item.code}] ${item.message}`));
1040
+ });
1041
+ console.log(chalk.dim('\n If this drift is intentional, regenerate baseline with `neurcode policy lock`.\n'));
1042
+ }
1043
+ process.exit(2);
1044
+ }
1045
+ }
734
1046
  // Check user tier - Policy Compliance and A-F Grading are PRO features
1047
+ const governance = (0, policy_governance_1.readPolicyGovernanceConfig)(projectRoot);
1048
+ const auditIntegrity = (0, policy_audit_1.verifyPolicyAuditIntegrity)(projectRoot);
1049
+ const auditIntegrityStatus = resolveAuditIntegrityStatus(governance.audit.requireIntegrity, auditIntegrity);
735
1050
  const { getUserTier } = await Promise.resolve().then(() => __importStar(require('../utils/tier')));
736
1051
  const tier = await getUserTier();
737
1052
  if (tier === 'FREE') {
@@ -759,36 +1074,105 @@ async function verifyCommand(options) {
759
1074
  totalPlannedFiles: 0,
760
1075
  message: 'Basic file change summary (PRO required for policy verification)',
761
1076
  scopeGuardPassed: false,
1077
+ mode: 'plan_enforced',
1078
+ policyOnly: false,
762
1079
  tier: 'FREE',
1080
+ policyLock: {
1081
+ enforced: policyLockEvaluation.enforced,
1082
+ matched: policyLockEvaluation.matched,
1083
+ path: policyLockEvaluation.lockPath,
1084
+ mismatches: policyLockEvaluation.mismatches,
1085
+ },
1086
+ policyGovernance: {
1087
+ exceptionApprovals: governance.exceptionApprovals,
1088
+ audit: {
1089
+ requireIntegrity: governance.audit.requireIntegrity,
1090
+ valid: auditIntegrityStatus.valid,
1091
+ issues: auditIntegrityStatus.issues,
1092
+ lastHash: auditIntegrity.lastHash,
1093
+ eventCount: auditIntegrity.count,
1094
+ },
1095
+ },
763
1096
  }, null, 2));
764
1097
  }
765
1098
  process.exit(0);
766
1099
  }
767
1100
  let policyViolations = [];
768
1101
  let policyDecision = 'allow';
769
- let effectiveRules = {
770
- allRules: (0, policy_engine_1.createDefaultPolicy)().rules,
771
- customRules: [],
772
- policyPackRules: [],
773
- policyPack: null,
1102
+ const diffFilesForPolicy = diffFiles.filter((f) => !shouldIgnore(f.path));
1103
+ const policyResult = (0, policy_engine_1.evaluateRules)(diffFilesForPolicy, effectiveRules.allRules);
1104
+ policyViolations = policyResult.violations.filter((v) => !shouldIgnore(v.file));
1105
+ const configuredPolicyExceptions = (0, policy_exceptions_1.readPolicyExceptions)(projectRoot);
1106
+ const exceptionDecision = (0, policy_exceptions_1.applyPolicyExceptions)(policyViolations, configuredPolicyExceptions, {
1107
+ requireApproval: governance.exceptionApprovals.required,
1108
+ minApprovals: governance.exceptionApprovals.minApprovals,
1109
+ disallowSelfApproval: governance.exceptionApprovals.disallowSelfApproval,
1110
+ allowedApprovers: governance.exceptionApprovals.allowedApprovers,
1111
+ });
1112
+ const suppressedPolicyViolations = exceptionDecision.suppressedViolations.filter((item) => !shouldIgnore(item.file));
1113
+ const blockedPolicyViolations = exceptionDecision.blockedViolations
1114
+ .filter((item) => !shouldIgnore(item.file))
1115
+ .map((item) => ({
1116
+ file: item.file,
1117
+ rule: item.rule,
1118
+ severity: 'block',
1119
+ message: `Exception ${item.exceptionId} cannot be applied: ${explainExceptionEligibilityReason(item.eligibilityReason)}`,
1120
+ ...(item.line != null ? { line: item.line } : {}),
1121
+ }));
1122
+ policyViolations = [
1123
+ ...exceptionDecision.remainingViolations.filter((item) => !shouldIgnore(item.file)),
1124
+ ...blockedPolicyViolations,
1125
+ ];
1126
+ if (governance.audit.requireIntegrity && !auditIntegrityStatus.valid) {
1127
+ policyViolations.push({
1128
+ file: POLICY_AUDIT_FILE,
1129
+ rule: 'policy_audit_integrity',
1130
+ severity: 'block',
1131
+ message: `Policy audit chain is invalid: ${auditIntegrityStatus.issues.join('; ') || 'unknown issue'}`,
1132
+ });
1133
+ }
1134
+ policyDecision = resolvePolicyDecisionFromViolations(policyViolations);
1135
+ const policyExceptionsSummary = {
1136
+ configured: configuredPolicyExceptions.length,
1137
+ active: exceptionDecision.activeExceptions.length,
1138
+ usable: exceptionDecision.usableExceptions.length,
1139
+ matched: exceptionDecision.matchedExceptionIds.length,
1140
+ suppressed: suppressedPolicyViolations.length,
1141
+ blocked: blockedPolicyViolations.length,
1142
+ matchedExceptionIds: exceptionDecision.matchedExceptionIds,
1143
+ suppressedViolations: suppressedPolicyViolations.map((item) => ({
1144
+ file: item.file,
1145
+ rule: item.rule,
1146
+ severity: item.severity,
1147
+ message: item.message,
1148
+ exceptionId: item.exceptionId,
1149
+ reason: item.reason,
1150
+ expiresAt: item.expiresAt,
1151
+ ...(item.line != null ? { startLine: item.line } : {}),
1152
+ })),
1153
+ blockedViolations: blockedPolicyViolations.map((item) => ({
1154
+ file: item.file,
1155
+ rule: item.rule,
1156
+ severity: item.severity,
1157
+ message: item.message,
1158
+ ...(item.line != null ? { startLine: item.line } : {}),
1159
+ })),
774
1160
  };
775
- try {
776
- effectiveRules = await buildEffectivePolicyRules(client, projectRoot, Boolean(config.apiKey));
777
- const diffFilesForPolicy = diffFiles.filter((f) => !shouldIgnore(f.path));
778
- const policyResult = (0, policy_engine_1.evaluateRules)(diffFilesForPolicy, effectiveRules.allRules);
779
- policyViolations = policyResult.violations.filter((v) => !shouldIgnore(v.file));
780
- policyDecision = policyViolations.length > 0 ? policyResult.decision : 'allow';
781
- if (!options.json && effectiveRules.customRules.length > 0) {
782
- console.log(chalk.dim(` Evaluating ${effectiveRules.customRules.length} custom policy rule(s) from dashboard`));
783
- }
784
- if (!options.json && effectiveRules.policyPack && effectiveRules.policyPackRules.length > 0) {
785
- console.log(chalk.dim(` Evaluating policy pack: ${effectiveRules.policyPack.packName} (${effectiveRules.policyPack.packId}@${effectiveRules.policyPack.version}, ${effectiveRules.policyPackRules.length} rule(s))`));
786
- }
1161
+ const policyGovernanceSummary = {
1162
+ exceptionApprovals: governance.exceptionApprovals,
1163
+ audit: {
1164
+ requireIntegrity: governance.audit.requireIntegrity,
1165
+ valid: auditIntegrityStatus.valid,
1166
+ issues: auditIntegrityStatus.issues,
1167
+ lastHash: auditIntegrity.lastHash,
1168
+ eventCount: auditIntegrity.count,
1169
+ },
1170
+ };
1171
+ if (!options.json && effectiveRules.customRules.length > 0) {
1172
+ console.log(chalk.dim(` Evaluating ${effectiveRules.customRules.length} custom policy rule(s) from dashboard`));
787
1173
  }
788
- catch (error) {
789
- if (!options.json) {
790
- console.log(chalk.dim(' Could not load dashboard custom policies, continuing with local/default rules only'));
791
- }
1174
+ if (!options.json && effectiveRules.policyPack && effectiveRules.policyPackRules.length > 0) {
1175
+ console.log(chalk.dim(` Evaluating policy pack: ${effectiveRules.policyPack.packName} (${effectiveRules.policyPack.packId}@${effectiveRules.policyPack.version}, ${effectiveRules.policyPackRules.length} rule(s))`));
792
1176
  }
793
1177
  // Prepare diff stats and changed files for API
794
1178
  const diffStats = {
@@ -834,9 +1218,12 @@ async function verifyCommand(options) {
834
1218
  // Apply custom policy verdict: block from dashboard overrides API verdict
835
1219
  const policyBlock = policyDecision === 'block' && policyViolations.length > 0;
836
1220
  const effectiveVerdict = policyBlock ? 'FAIL' : verifyResult.verdict;
837
- const effectiveMessage = policyBlock
1221
+ const policyMessageBase = policyBlock
838
1222
  ? `Custom policy violations: ${policyViolations.map(v => `${v.file}: ${v.message || v.rule}`).join('; ')}. ${verifyResult.message}`
839
1223
  : verifyResult.message;
1224
+ const effectiveMessage = policyExceptionsSummary.suppressed > 0
1225
+ ? `${policyMessageBase} Policy exceptions suppressed ${policyExceptionsSummary.suppressed} violation(s).`
1226
+ : policyMessageBase;
840
1227
  // Calculate grade from effective verdict and score
841
1228
  // CRITICAL: 0/0 planned files = 'F' (Incomplete), not 'B'
842
1229
  // Bloat automatically drops grade by at least one letter
@@ -882,7 +1269,13 @@ async function verifyCommand(options) {
882
1269
  const changedPathsForBrain = diffFiles
883
1270
  .flatMap((file) => [file.path, file.oldPath])
884
1271
  .filter((value) => Boolean(value));
885
- recordVerifyEvent(effectiveVerdict, `adherence=${verifyResult.adherenceScore};bloat=${verifyResult.bloatCount};scopeGuard=${scopeGuardPassed ? 1 : 0};policy=${policyDecision}`, changedPathsForBrain, finalPlanId);
1272
+ recordVerifyEvent(effectiveVerdict, `adherence=${verifyResult.adherenceScore};bloat=${verifyResult.bloatCount};scopeGuard=${scopeGuardPassed ? 1 : 0};policy=${policyDecision};policyExceptions=${policyExceptionsSummary.suppressed}`, changedPathsForBrain, finalPlanId);
1273
+ const shouldForceGovernancePass = scopeGuardPassed &&
1274
+ !policyBlock &&
1275
+ (effectiveVerdict === 'PASS' ||
1276
+ ((verifyResult.verdict === 'FAIL' || verifyResult.verdict === 'WARN') &&
1277
+ policyViolations.length === 0 &&
1278
+ verifyResult.bloatCount > 0));
886
1279
  // If JSON output requested, output JSON and exit
887
1280
  if (options.json) {
888
1281
  const filteredBloatFiles = (verifyResult.bloatFiles || []).filter((f) => !shouldIgnore(f));
@@ -912,6 +1305,16 @@ async function verifyCommand(options) {
912
1305
  bloatFiles: filteredBloatFiles,
913
1306
  plannedFilesModified: verifyResult.plannedFilesModified,
914
1307
  totalPlannedFiles: verifyResult.totalPlannedFiles,
1308
+ mode: 'plan_enforced',
1309
+ policyOnly: false,
1310
+ policyLock: {
1311
+ enforced: policyLockEvaluation.enforced,
1312
+ matched: policyLockEvaluation.matched,
1313
+ path: policyLockEvaluation.lockPath,
1314
+ mismatches: policyLockEvaluation.mismatches,
1315
+ },
1316
+ policyExceptions: policyExceptionsSummary,
1317
+ policyGovernance: policyGovernanceSummary,
915
1318
  ...(policyViolations.length > 0 && { policyDecision }),
916
1319
  ...(effectiveRules.policyPack
917
1320
  ? {
@@ -948,7 +1351,7 @@ async function verifyCommand(options) {
948
1351
  });
949
1352
  }
950
1353
  // Exit based on effective verdict (same logic as below)
951
- if (scopeGuardPassed && !policyBlock) {
1354
+ if (shouldForceGovernancePass) {
952
1355
  process.exit(0);
953
1356
  }
954
1357
  if (effectiveVerdict === 'FAIL') {
@@ -971,6 +1374,25 @@ async function verifyCommand(options) {
971
1374
  bloatFiles: displayBloatFiles,
972
1375
  bloatCount: displayBloatFiles.length,
973
1376
  }, policyViolations);
1377
+ if (policyExceptionsSummary.suppressed > 0) {
1378
+ console.log(chalk.yellow(`\n⚠️ Policy exceptions applied: ${policyExceptionsSummary.suppressed}`));
1379
+ if (policyExceptionsSummary.matchedExceptionIds.length > 0) {
1380
+ console.log(chalk.dim(` Exception IDs: ${policyExceptionsSummary.matchedExceptionIds.join(', ')}`));
1381
+ }
1382
+ }
1383
+ if (policyExceptionsSummary.blocked > 0) {
1384
+ console.log(chalk.red(`\n⛔ Policy exceptions blocked by approval governance: ${policyExceptionsSummary.blocked}`));
1385
+ const sample = policyExceptionsSummary.blockedViolations.slice(0, 5);
1386
+ sample.forEach((item) => {
1387
+ console.log(chalk.red(` • ${item.file}: ${item.message || item.rule}`));
1388
+ });
1389
+ }
1390
+ if (governance.audit.requireIntegrity && !auditIntegrityStatus.valid) {
1391
+ console.log(chalk.red('\n⛔ Policy audit integrity enforcement is enabled and chain verification failed.'));
1392
+ auditIntegrityStatus.issues.slice(0, 5).forEach((issue) => {
1393
+ console.log(chalk.red(` • ${issue}`));
1394
+ });
1395
+ }
974
1396
  }
975
1397
  // Report to Neurcode Cloud if --record flag is set
976
1398
  if (options.record) {
@@ -1001,9 +1423,9 @@ async function verifyCommand(options) {
1001
1423
  );
1002
1424
  }
1003
1425
  }
1004
- // Governance takes priority over Grading
1005
- // If Scope Guard passed (all files approved or allowed) and no policy block, always PASS
1006
- if (scopeGuardPassed && !policyBlock) {
1426
+ // Governance override: keep PASS only when scope guard passes and failure is due
1427
+ // to server-side bloat mismatch (allowed files unknown to verify API).
1428
+ if (shouldForceGovernancePass) {
1007
1429
  if ((verifyResult.verdict === 'FAIL' || verifyResult.verdict === 'WARN') && policyViolations.length === 0) {
1008
1430
  if (!options.json) {
1009
1431
  console.log(chalk.yellow('\n⚠️ Plan deviation allowed'));
@@ -1011,6 +1433,9 @@ async function verifyCommand(options) {
1011
1433
  console.log(chalk.dim(' Governance check passed - proceeding with exit code 0.\n'));
1012
1434
  }
1013
1435
  }
1436
+ if (!options.json && policyExceptionsSummary.suppressed > 0) {
1437
+ console.log(chalk.yellow(` Policy exceptions applied: ${policyExceptionsSummary.suppressed}`));
1438
+ }
1014
1439
  process.exit(0);
1015
1440
  }
1016
1441
  // If scope guard didn't pass (or failed to check) or policy blocked, use effective verdict