@provos/ironcurtain 0.9.0 → 0.10.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.
Files changed (96) hide show
  1. package/README.md +20 -12
  2. package/dist/cli.js +2 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/config-command.js +12 -3
  5. package/dist/config/config-command.js.map +1 -1
  6. package/dist/config/model-provider.d.ts +8 -2
  7. package/dist/config/model-provider.js +52 -9
  8. package/dist/config/model-provider.js.map +1 -1
  9. package/dist/config/paths.d.ts +6 -0
  10. package/dist/config/paths.js +18 -9
  11. package/dist/config/paths.js.map +1 -1
  12. package/dist/config/user-config.d.ts +9 -0
  13. package/dist/config/user-config.js +26 -5
  14. package/dist/config/user-config.js.map +1 -1
  15. package/dist/cron/compile-task-policy.js +1 -0
  16. package/dist/cron/compile-task-policy.js.map +1 -1
  17. package/dist/docker/adapters/claude-code.d.ts +2 -1
  18. package/dist/docker/adapters/claude-code.js +135 -120
  19. package/dist/docker/adapters/claude-code.js.map +1 -1
  20. package/dist/docker/agent-registry.js +3 -2
  21. package/dist/docker/agent-registry.js.map +1 -1
  22. package/dist/docker/container-lifecycle.d.ts +15 -0
  23. package/dist/docker/container-lifecycle.js +32 -0
  24. package/dist/docker/container-lifecycle.js.map +1 -0
  25. package/dist/docker/docker-agent-session.js +16 -23
  26. package/dist/docker/docker-agent-session.js.map +1 -1
  27. package/dist/docker/docker-infrastructure.d.ts +10 -0
  28. package/dist/docker/docker-infrastructure.js +71 -4
  29. package/dist/docker/docker-infrastructure.js.map +1 -1
  30. package/dist/docker/docker-manager.js +37 -0
  31. package/dist/docker/docker-manager.js.map +1 -1
  32. package/dist/docker/mitm-proxy.d.ts +6 -0
  33. package/dist/docker/mitm-proxy.js +343 -25
  34. package/dist/docker/mitm-proxy.js.map +1 -1
  35. package/dist/docker/oauth-credentials.d.ts +45 -6
  36. package/dist/docker/oauth-credentials.js +151 -24
  37. package/dist/docker/oauth-credentials.js.map +1 -1
  38. package/dist/docker/oauth-token-manager.d.ts +15 -16
  39. package/dist/docker/oauth-token-manager.js +23 -17
  40. package/dist/docker/oauth-token-manager.js.map +1 -1
  41. package/dist/docker/provider-config.d.ts +32 -0
  42. package/dist/docker/provider-config.js +20 -0
  43. package/dist/docker/provider-config.js.map +1 -1
  44. package/dist/docker/pty-session.js +14 -22
  45. package/dist/docker/pty-session.js.map +1 -1
  46. package/dist/docker/types.d.ts +8 -0
  47. package/dist/index.js +8 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/mux/mux-app.d.ts +2 -0
  50. package/dist/mux/mux-app.js +48 -37
  51. package/dist/mux/mux-app.js.map +1 -1
  52. package/dist/mux/mux-command.js +7 -1
  53. package/dist/mux/mux-command.js.map +1 -1
  54. package/dist/mux/mux-input-handler.d.ts +2 -0
  55. package/dist/mux/mux-input-handler.js +6 -4
  56. package/dist/mux/mux-input-handler.js.map +1 -1
  57. package/dist/mux/mux-renderer.js +20 -5
  58. package/dist/mux/mux-renderer.js.map +1 -1
  59. package/dist/mux/pty-bridge.d.ts +2 -0
  60. package/dist/mux/pty-bridge.js +3 -0
  61. package/dist/mux/pty-bridge.js.map +1 -1
  62. package/dist/mux/session-scanner.d.ts +7 -0
  63. package/dist/mux/session-scanner.js +21 -1
  64. package/dist/mux/session-scanner.js.map +1 -1
  65. package/dist/mux/types.d.ts +0 -4
  66. package/dist/mux/types.js.map +1 -1
  67. package/dist/persona/compile-persona-policy.js +1 -0
  68. package/dist/persona/compile-persona-policy.js.map +1 -1
  69. package/dist/pipeline/annotate.d.ts +7 -1
  70. package/dist/pipeline/annotate.js +92 -2
  71. package/dist/pipeline/annotate.js.map +1 -1
  72. package/dist/pipeline/compile.js +2 -0
  73. package/dist/pipeline/compile.js.map +1 -1
  74. package/dist/pipeline/llm-logger.d.ts +1 -0
  75. package/dist/pipeline/llm-logger.js +8 -1
  76. package/dist/pipeline/llm-logger.js.map +1 -1
  77. package/dist/pipeline/parallel-progress.d.ts +59 -0
  78. package/dist/pipeline/parallel-progress.js +263 -0
  79. package/dist/pipeline/parallel-progress.js.map +1 -0
  80. package/dist/pipeline/pipeline-runner.d.ts +49 -8
  81. package/dist/pipeline/pipeline-runner.js +375 -226
  82. package/dist/pipeline/pipeline-runner.js.map +1 -1
  83. package/dist/pipeline/pipeline-shared.d.ts +58 -0
  84. package/dist/pipeline/pipeline-shared.js +124 -1
  85. package/dist/pipeline/pipeline-shared.js.map +1 -1
  86. package/dist/pipeline/server-prefilter.d.ts +31 -0
  87. package/dist/pipeline/server-prefilter.js +115 -0
  88. package/dist/pipeline/server-prefilter.js.map +1 -0
  89. package/dist/pipeline/types.d.ts +5 -0
  90. package/dist/session/preflight.js +15 -16
  91. package/dist/session/preflight.js.map +1 -1
  92. package/dist/trusted-process/sandbox-integration.js +8 -0
  93. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  94. package/docker/Dockerfile.base +3 -1
  95. package/docker/Dockerfile.base.arm64 +8 -1
  96. package/package.json +4 -1
@@ -12,26 +12,33 @@
12
12
  */
13
13
  import { resolve } from 'node:path';
14
14
  import chalk from 'chalk';
15
+ import pLimit from 'p-limit';
15
16
  import { extractServerDomainAllowlists } from '../config/index.js';
17
+ import { createLanguageModel } from '../config/model-provider.js';
18
+ import { loadUserConfig } from '../config/user-config.js';
16
19
  import { PolicyEngine } from '../trusted-process/policy-engine.js';
17
20
  import { buildCompilerSystemPrompt, ConstitutionCompilerSession, formatAnnotationsSummary, validateCompiledRules, } from './constitution-compiler.js';
18
21
  import { getHandwrittenScenarios } from './handwritten-scenarios.js';
19
22
  import { resolveAllLists } from './list-resolver.js';
20
- import { computeHash, loadExistingArtifact, loadStoredToolAnnotationsFile, resolveRulePaths, writeArtifact, withSpinner, showCached, createPipelineLlm, } from './pipeline-shared.js';
23
+ import { computeHash, loadExistingArtifact, loadStoredToolAnnotationsFile, resolveRulePaths, writeArtifact, createPipelineLlm, createPerServerModel, createThrottledModel, SpinnerProgressReporter, } from './pipeline-shared.js';
21
24
  import { resolveStoredAnnotationsFile } from '../types/argument-roles.js';
22
25
  import { applyScenarioCorrections, buildJudgeSystemPrompt, detectAllDefaultRoleFallbacks, extractScenarioCorrections, filterStructuralConflicts, PolicyVerifierSession, verifyPolicy, } from './policy-verifier.js';
23
26
  import { filterInvalidSchemaScenarios } from './scenario-schema-validator.js';
24
27
  import { buildGeneratorSystemPrompt, generateScenarios, repairScenarios } from './scenario-generator.js';
28
+ import { prefilterServers } from './server-prefilter.js';
25
29
  import { connectViaProxy } from './proxy-mcp-connections.js';
26
30
  /** Creates PipelineModels from user config. Delegates to shared createPipelineLlm. */
27
31
  export async function createPipelineModels(logDir) {
28
32
  const effectiveLogDir = logDir ?? resolve(process.cwd(), 'generated');
29
33
  const llm = await createPipelineLlm(effectiveLogDir, 'unknown');
34
+ const userConfig = loadUserConfig();
35
+ const haikuBaseLlm = await createLanguageModel(userConfig.prefilterModelId, userConfig);
36
+ const { model: prefilterModel } = createPerServerModel(haikuBaseLlm, llm.logPath, 'prefilter');
30
37
  return {
31
- compilationModel: llm.model,
38
+ baseLlm: llm.baseLlm,
32
39
  cacheStrategy: llm.cacheStrategy,
33
- logContext: llm.logContext,
34
40
  logPath: llm.logPath,
41
+ prefilterModel,
35
42
  };
36
43
  }
37
44
  // ---------------------------------------------------------------------------
@@ -52,7 +59,8 @@ function extractPermittedDirectories(rules) {
52
59
  function collectProbeScenarios(result) {
53
60
  return result.rounds.flatMap((round) => round.newScenarios);
54
61
  }
55
- function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded scenario (structural conflict)', storedAnnotations) {
62
+ function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded scenario (structural conflict)', storedAnnotations, reporter) {
63
+ const warn = (msg) => (reporter ? reporter.warn(msg) : console.error(msg));
56
64
  const filterResult = filterStructuralConflicts(engine, scenarios);
57
65
  let valid = filterResult.valid;
58
66
  const discarded = filterResult.discarded;
@@ -60,7 +68,7 @@ function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded s
60
68
  const prefix = d.scenario.source === 'handwritten'
61
69
  ? chalk.yellow('Warning: handwritten scenario conflicts with structural invariant:')
62
70
  : chalk.dim(`${label}:`);
63
- console.error(` ${prefix} "${d.scenario.description}" — ${d.rule} always returns ${d.actual}`);
71
+ warn(` ${prefix} "${d.scenario.description}" — ${d.rule} always returns ${d.actual}`);
64
72
  }
65
73
  // Discard scenarios whose arguments don't match any conditional role spec —
66
74
  // they fall back to all default roles and test the wrong thing.
@@ -74,7 +82,7 @@ function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded s
74
82
  const w = fallbackWarnings.find((fw) => fw.scenario.description === s.description);
75
83
  const details = w?.details.join('; ') ?? 'unknown conditional mismatch';
76
84
  const discardLabel = 'Discarded (default role fallback)';
77
- console.error(` ${chalk.dim(`${discardLabel}:`)} "${s.description}" — ${details}`);
85
+ warn(` ${chalk.dim(`${discardLabel}:`)} "${s.description}" — ${details}`);
78
86
  discarded.push({ scenario: s, rule: 'default-role-fallback', actual: 'deny' });
79
87
  }
80
88
  else {
@@ -88,7 +96,7 @@ function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded s
88
96
  const schemaResult = filterInvalidSchemaScenarios(valid, storedAnnotations);
89
97
  if (schemaResult.discarded.length > 0) {
90
98
  for (const d of schemaResult.discarded) {
91
- console.error(` ${chalk.dim('Discarded (schema mismatch):')} "${d.scenario.description}" — ${d.rule}`);
99
+ warn(` ${chalk.dim('Discarded (schema mismatch):')} "${d.scenario.description}" — ${d.rule}`);
92
100
  }
93
101
  discarded.push(...schemaResult.discarded);
94
102
  valid = schemaResult.valid;
@@ -96,6 +104,10 @@ function filterAndLogStructuralConflicts(engine, scenarios, label = 'Discarded s
96
104
  }
97
105
  return { valid, discarded };
98
106
  }
107
+ /** Coerce an unknown thrown value into an Error. */
108
+ function toError(err) {
109
+ return err instanceof Error ? err : new Error(String(err));
110
+ }
99
111
  class RuleValidationError extends Error {
100
112
  errors;
101
113
  constructor(errors) {
@@ -104,11 +116,12 @@ class RuleValidationError extends Error {
104
116
  this.name = 'RuleValidationError';
105
117
  }
106
118
  }
107
- function validateRulesOrThrow(rules, listDefinitions = []) {
119
+ function validateRulesOrThrow(rules, listDefinitions = [], reporter) {
120
+ const warn = (msg) => (reporter ? reporter.warn(msg) : console.error(msg));
108
121
  const ruleValidation = validateCompiledRules(rules, listDefinitions);
109
122
  if (ruleValidation.warnings.length > 0) {
110
123
  for (const w of ruleValidation.warnings) {
111
- console.error(` ${chalk.yellow('Warning:')} ${w}`);
124
+ warn(` ${chalk.yellow('Warning:')} ${w}`);
112
125
  }
113
126
  }
114
127
  if (!ruleValidation.valid) {
@@ -262,7 +275,7 @@ export function deduplicateListDefinitions(defs) {
262
275
  * Rules are concatenated in alphabetical server order for determinism.
263
276
  * List definitions are deduplicated by name.
264
277
  */
265
- export function mergeServerResults(results, constitutionHash) {
278
+ export function mergeServerResults(results, constitutionHash, skippedServers) {
266
279
  const sortedResults = [...results].sort((a, b) => a.serverName.localeCompare(b.serverName));
267
280
  const allRules = sortedResults.flatMap((r) => r.rules);
268
281
  const allListDefs = sortedResults.flatMap((r) => r.listDefinitions);
@@ -277,6 +290,12 @@ export function mergeServerResults(results, constitutionHash) {
277
290
  if (uniqueListDefs.length > 0) {
278
291
  artifact.listDefinitions = uniqueListDefs;
279
292
  }
293
+ // skippedServers is informational metadata — not read by PolicyEngine.
294
+ // It's intentionally excluded from inputHash; the constitutionHash already
295
+ // changes when the constitution changes, which drives different skip decisions.
296
+ if (skippedServers && skippedServers.length > 0) {
297
+ artifact.skippedServers = skippedServers;
298
+ }
280
299
  return artifact;
281
300
  }
282
301
  // ---------------------------------------------------------------------------
@@ -291,13 +310,15 @@ export function mergeServerResults(results, constitutionHash) {
291
310
  * (broad principles) and 'task-policy' (strict whitelist) modes.
292
311
  */
293
312
  export class PipelineRunner {
294
- model;
313
+ baseLlm;
295
314
  cacheStrategy;
296
- logContext;
315
+ logPath;
316
+ prefilterModel;
297
317
  constructor(models) {
298
- this.model = models.compilationModel;
318
+ this.baseLlm = models.baseLlm;
299
319
  this.cacheStrategy = models.cacheStrategy;
300
- this.logContext = models.logContext;
320
+ this.prefilterModel = models.prefilterModel;
321
+ this.logPath = models.logPath;
301
322
  }
302
323
  /**
303
324
  * Runs the full pipeline. Returns the compiled policy on success.
@@ -330,10 +351,10 @@ export class PipelineRunner {
330
351
  // Resolve conditional role specs to flat annotations for compiler prompts
331
352
  const toolAnnotationsFile = resolveStoredAnnotationsFile(storedAnnotationsFile);
332
353
  const constitutionHash = computeHash(config.constitutionInput);
333
- // Phase 1: Per-server compilation
334
- const serverResults = await this.compileAllServers(config, toolAnnotationsFile, constitutionHash, storedAnnotationsFile);
354
+ // Phase 1: Per-server compilation (with optional Haiku pre-filter)
355
+ const { results: serverResults, skippedServers } = await this.compileAllServers(config, toolAnnotationsFile, constitutionHash, storedAnnotationsFile);
335
356
  // Phase 2: Merge
336
- const mergedPolicy = mergeServerResults(serverResults, constitutionHash);
357
+ const mergedPolicy = mergeServerResults(serverResults, constitutionHash, skippedServers);
337
358
  writeArtifact(config.outputDir, 'compiled-policy.json', mergedPolicy);
338
359
  // Merge scenarios for the global artifact
339
360
  const allScenarios = serverResults.flatMap((r) => r.scenarios);
@@ -391,11 +412,11 @@ export class PipelineRunner {
391
412
  return mergedPolicy;
392
413
  }
393
414
  /**
394
- * Compiles rules for all servers sequentially.
395
- * Respects serverFilter if provided (for debugging single servers).
415
+ * Compiles rules for all servers, in parallel when multiple servers are present.
416
+ * Single-server compilation uses sequential spinner-based output.
417
+ * Multi-server compilation uses parallel execution with a multi-line progress display.
396
418
  */
397
419
  async compileAllServers(config, toolAnnotationsFile, constitutionHash, storedAnnotationsFile) {
398
- const results = [];
399
420
  const serverEntries = Object.entries(toolAnnotationsFile.servers);
400
421
  // Apply server filter if provided
401
422
  const { serverFilter } = config;
@@ -406,35 +427,126 @@ export class PipelineRunner {
406
427
  throw new Error(`No matching servers found for filter: ${config.serverFilter.join(', ')}. ` +
407
428
  `Available: ${serverEntries.map(([n]) => n).join(', ')}`);
408
429
  }
430
+ // Pre-filter with Haiku: skip servers irrelevant to the input text
431
+ let entriesToCompile = filteredEntries;
432
+ const skippedServers = [];
433
+ if (config.prefilterText !== undefined && !config.serverFilter) {
434
+ const prefilterText = config.prefilterText;
435
+ if (prefilterText.trim() === '') {
436
+ console.error(chalk.yellow('Pre-filter input is empty. All servers skipped (default-deny applies).'));
437
+ const allSkipped = filteredEntries.map(([name]) => ({
438
+ serverName: name,
439
+ reason: 'No input text provided — all servers skipped by default',
440
+ }));
441
+ return { results: [], skippedServers: allSkipped };
442
+ }
443
+ const serverToolPairs = filteredEntries.map(([name]) => [name, toolAnnotationsFile.servers[name].tools]);
444
+ const decisions = await prefilterServers(prefilterText, serverToolPairs, this.prefilterModel, config.constitutionKind);
445
+ const decisionMap = new Map(decisions.map((d) => [d.serverName, d]));
446
+ entriesToCompile = filteredEntries.filter(([name]) => {
447
+ const decision = decisionMap.get(name);
448
+ if (decision?.skip) {
449
+ console.error(` ${chalk.dim(name)}: ${chalk.yellow('skipped')} — ${decision.reason}`);
450
+ skippedServers.push({ serverName: name, reason: decision.reason });
451
+ return false;
452
+ }
453
+ return true;
454
+ });
455
+ const skippedCount = filteredEntries.length - entriesToCompile.length;
456
+ if (skippedCount > 0) {
457
+ console.error(` Pre-filter: ${skippedCount} server(s) skipped, ${entriesToCompile.length} proceeding`);
458
+ }
459
+ }
460
+ if (entriesToCompile.length === 0) {
461
+ console.error(chalk.yellow('All servers skipped by pre-filter. Policy will use default-deny only.'));
462
+ return { results: [], skippedServers };
463
+ }
409
464
  // Load handwritten scenarios once, then filter per server (avoids N redundant loads)
410
465
  const includeHandwritten = config.includeHandwrittenScenarios ?? config.constitutionKind === 'constitution';
411
466
  const allHandwrittenScenarios = includeHandwritten ? getHandwrittenScenarios(config.allowedDirectory) : [];
467
+ // Create concurrency controls
468
+ const llmSemaphore = pLimit(8);
469
+ const serverLimit = pLimit(10);
470
+ const buildUnit = (serverName) => ({
471
+ serverName,
472
+ annotations: toolAnnotationsFile.servers[serverName].tools,
473
+ storedAnnotations: storedAnnotationsFile.servers[serverName].tools,
474
+ constitutionText: config.constitutionInput,
475
+ constitutionKind: config.constitutionKind,
476
+ allowedDirectory: config.allowedDirectory,
477
+ protectedPaths: config.protectedPaths,
478
+ mcpServerConfig: config.mcpServers?.[serverName],
479
+ handwrittenScenarios: allHandwrittenScenarios.filter((s) => s.request.serverName === serverName),
480
+ });
481
+ const useParallel = entriesToCompile.length > 1;
482
+ let results;
483
+ if (useParallel) {
484
+ results = await this.compileServersParallel(entriesToCompile, config, constitutionHash, buildUnit, llmSemaphore, serverLimit);
485
+ }
486
+ else {
487
+ results = await this.compileServersSequential(entriesToCompile, config, constitutionHash, buildUnit, llmSemaphore);
488
+ }
489
+ return { results, skippedServers };
490
+ }
491
+ /** Creates a per-server model wrapped with LLM call throttling. */
492
+ createServerModel(serverName, llmSemaphore) {
493
+ const { model, logContext } = createPerServerModel(this.baseLlm, this.logPath, serverName);
494
+ return { model: createThrottledModel(model, llmSemaphore), logContext };
495
+ }
496
+ async compileServersSequential(filteredEntries, config, constitutionHash, buildUnit, llmSemaphore) {
497
+ const results = [];
412
498
  const totalServers = filteredEntries.length;
413
499
  const failedServers = [];
414
500
  for (let i = 0; i < filteredEntries.length; i++) {
415
- const [serverName, serverData] = filteredEntries[i];
501
+ const [serverName] = filteredEntries[i];
416
502
  console.error('');
417
503
  console.error(chalk.bold(`[${i + 1}/${totalServers}] Compiling server: ${serverName}`));
504
+ const { model, logContext } = this.createServerModel(serverName, llmSemaphore);
505
+ const reporter = new SpinnerProgressReporter(serverName);
418
506
  try {
419
- const result = await this.compileServer({
420
- serverName,
421
- annotations: serverData.tools,
422
- storedAnnotations: storedAnnotationsFile.servers[serverName].tools,
423
- constitutionText: config.constitutionInput,
424
- constitutionKind: config.constitutionKind,
425
- allowedDirectory: config.allowedDirectory,
426
- protectedPaths: config.protectedPaths,
427
- mcpServerConfig: config.mcpServers?.[serverName],
428
- handwrittenScenarios: allHandwrittenScenarios.filter((s) => s.request.serverName === serverName),
429
- }, config, constitutionHash);
507
+ const result = await this.compileServer(buildUnit(serverName), config, constitutionHash, model, logContext, reporter);
430
508
  results.push(result);
431
509
  }
432
510
  catch (err) {
433
- const msg = err instanceof Error ? err.message : String(err);
434
- console.error(` ${chalk.red(`Server "${serverName}" failed:`)} ${msg}`);
511
+ const coerced = toError(err);
512
+ reporter.fail('compiling', coerced);
513
+ console.error(` ${chalk.red(`Server "${serverName}" failed:`)} ${coerced.message}`);
435
514
  failedServers.push(serverName);
436
515
  }
437
516
  }
517
+ return this.handleCompilationResults(results, failedServers);
518
+ }
519
+ async compileServersParallel(filteredEntries, config, constitutionHash, buildUnit, llmSemaphore, serverLimit) {
520
+ // Lazy import to avoid loading parallel-progress.ts when not needed
521
+ const { ParallelProgressDisplay, ParallelProgressReporter } = await import('./parallel-progress.js');
522
+ const serverNames = filteredEntries.map(([name]) => name);
523
+ const display = new ParallelProgressDisplay(serverNames);
524
+ const settled = await Promise.allSettled(filteredEntries.map(([serverName]) => serverLimit(async () => {
525
+ const { model, logContext } = this.createServerModel(serverName, llmSemaphore);
526
+ const reporter = new ParallelProgressReporter(display, serverName);
527
+ try {
528
+ return await this.compileServer(buildUnit(serverName), config, constitutionHash, model, logContext, reporter);
529
+ }
530
+ catch (err) {
531
+ reporter.fail('compiling', toError(err));
532
+ throw err;
533
+ }
534
+ })));
535
+ display.finish();
536
+ const results = [];
537
+ const failedServers = [];
538
+ for (const [i, outcome] of settled.entries()) {
539
+ if (outcome.status === 'fulfilled') {
540
+ results.push(outcome.value);
541
+ }
542
+ else {
543
+ const serverName = filteredEntries[i][0];
544
+ failedServers.push(serverName);
545
+ }
546
+ }
547
+ return this.handleCompilationResults(results, failedServers);
548
+ }
549
+ handleCompilationResults(results, failedServers) {
438
550
  if (failedServers.length > 0) {
439
551
  console.error(`\n${chalk.yellow(`Warning: ${failedServers.length} server(s) failed verification: ${failedServers.join(', ')}`)}`);
440
552
  console.error(chalk.yellow('Continuing with successfully compiled servers.'));
@@ -448,8 +560,12 @@ export class PipelineRunner {
448
560
  /**
449
561
  * Compiles a single server: compile -> generate scenarios -> verify -> repair.
450
562
  * Self-contained compile-verify-repair loop per server.
563
+ *
564
+ * Each server receives its own model (with per-server logging middleware),
565
+ * logContext (for step labeling), and reporter (for progress/warnings).
566
+ * This makes the method safe for concurrent execution.
451
567
  */
452
- async compileServer(unit, config, constitutionHash) {
568
+ async compileServer(unit, config, constitutionHash, model, logContext, reporter) {
453
569
  const serverOutputDir = resolve(config.outputDir, 'servers', unit.serverName);
454
570
  // Build per-server system prompt (only this server's annotations)
455
571
  const compilerPrompt = unit.constitutionKind === 'task-policy'
@@ -460,13 +576,12 @@ export class PipelineRunner {
460
576
  const existingServerPolicy = loadExistingArtifact(serverOutputDir, 'compiled-policy.json');
461
577
  const existingServerScenarios = loadExistingArtifact(serverOutputDir, 'test-scenarios.json');
462
578
  if (existingServerPolicy && existingServerPolicy.inputHash === inputHash && existingServerScenarios) {
463
- // Verify scenario hash to detect stale scenarios (e.g., changed templates or handwritten scenarios)
464
579
  const cachedPermittedDirs = extractPermittedDirectories(resolveRulePaths(existingServerPolicy.rules));
465
580
  const cachedDynamicLists = loadExistingArtifact(serverOutputDir, 'dynamic-lists.json');
466
581
  const cachedScenarioPrompt = buildGeneratorSystemPrompt(unit.constitutionText, unit.annotations, unit.allowedDirectory, cachedPermittedDirs, cachedDynamicLists, unit.storedAnnotations);
467
582
  const cachedScenarioHash = computeScenariosHash(cachedScenarioPrompt, unit.handwrittenScenarios);
468
583
  if (existingServerScenarios.inputHash === cachedScenarioHash) {
469
- showCached(` ${unit.serverName}: compilation`);
584
+ reporter.complete('cached', ` ${unit.serverName}: compilation ${chalk.dim('(cached)')}`, 0);
470
585
  return {
471
586
  serverName: unit.serverName,
472
587
  rules: resolveRulePaths(existingServerPolicy.rules),
@@ -480,20 +595,19 @@ export class PipelineRunner {
480
595
  }
481
596
  const compilerSystem = this.cacheStrategy.wrapSystemPrompt(compilerPrompt);
482
597
  // Step 1: Compile rules for this server
483
- this.logContext.stepName = `compile-${unit.serverName}`;
484
- const compileResult = await this.compileServerPolicyRules(unit, compilerSystem, inputHash);
598
+ logContext.stepName = `compile-${unit.serverName}`;
599
+ const compileResult = await this.compileServerPolicyRules(unit, compilerSystem, inputHash, model, reporter);
485
600
  let { rules } = compileResult;
486
601
  let { listDefinitions } = compileResult;
487
- let compilerSession = compileResult.session;
488
602
  // Validate server scoping (debug assertion)
489
603
  validateServerScoping(unit.serverName, rules);
490
604
  // Resolve dynamic lists for this server (before scenario generation so lists are available)
491
605
  let dynamicLists;
492
606
  if (listDefinitions.length > 0) {
493
- dynamicLists = await this.resolveServerLists(listDefinitions, serverOutputDir, config, ` ${unit.serverName}`);
607
+ dynamicLists = await this.resolveServerLists(listDefinitions, serverOutputDir, config, ` ${unit.serverName}`, model, logContext, reporter);
494
608
  }
495
609
  // Build per-server policy artifact for engine construction
496
- let serverPolicyFile = buildPolicyArtifact(constitutionHash, rules, listDefinitions, inputHash);
610
+ const serverPolicyFile = buildPolicyArtifact(constitutionHash, rules, listDefinitions, inputHash);
497
611
  // Persist early so intermediate results survive scenario/verification failures
498
612
  const writeServerPolicy = () => writeArtifact(serverOutputDir, 'compiled-policy.json', {
499
613
  generatedAt: new Date().toISOString(),
@@ -514,143 +628,18 @@ export class PipelineRunner {
514
628
  ? extractServerDomainAllowlists({ [unit.serverName]: unit.mcpServerConfig })
515
629
  : undefined;
516
630
  // Step 2: Generate test scenarios for this server
517
- this.logContext.stepName = `scenarios-${unit.serverName}`;
631
+ logContext.stepName = `scenarios-${unit.serverName}`;
518
632
  const permittedDirectories = extractPermittedDirectories(rules);
519
633
  const scenarioPrompt = buildGeneratorSystemPrompt(unit.constitutionText, unit.annotations, unit.allowedDirectory, permittedDirectories, dynamicLists, unit.storedAnnotations);
520
634
  const scenarioHash = computeScenariosHash(scenarioPrompt, unit.handwrittenScenarios);
521
- const scenarioResult = await this.generateTestScenarios(unit.constitutionText, unit.annotations, unit.allowedDirectory, unit.handwrittenScenarios, scenarioHash, existingServerScenarios, ` ${unit.serverName}`, permittedDirectories, dynamicLists, unit.storedAnnotations);
522
- // Step 3: Verify compiled rules against scenarios
523
- this.logContext.stepName = `verify-${unit.serverName}`;
524
- const filterEngine = new PolicyEngine(serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, unit.allowedDirectory, undefined, dynamicLists);
525
- const { valid: filteredScenarios, discarded: discardedScenarios } = filterAndLogStructuralConflicts(filterEngine, scenarioResult.scenarios, undefined, unit.storedAnnotations);
526
- // Generate replacement scenarios for structurally discarded ones (pre-loop, one-time)
527
- if (discardedScenarios.length > 0) {
528
- const discardedForRepair = discardedScenarios.map((d) => ({
529
- scenario: d.scenario,
530
- feedback: `${d.rule} always returns ${d.actual}`,
531
- }));
532
- this.logContext.stepName = `repair-scenarios-${unit.serverName}`;
533
- const replacementScenarios = await repairScenarios(discardedForRepair, unit.constitutionText, unit.annotations, unit.allowedDirectory, this.model, permittedDirectories, dynamicLists, (msg) => console.error(` ${chalk.dim(msg)}`), unit.storedAnnotations);
534
- if (replacementScenarios.length > 0) {
535
- // Filter replacements through structural invariants too
536
- const { valid: validReplacements } = filterAndLogStructuralConflicts(filterEngine, replacementScenarios, 'Discarded replacement (structural conflict)', unit.storedAnnotations);
537
- scenarioResult.scenarios.push(...validReplacements);
538
- filteredScenarios.push(...validReplacements);
539
- console.error(` ${chalk.dim(`Repaired ${discardedScenarios.length} discarded scenario(s) → ${validReplacements.length} replacement(s)`)}`);
540
- }
541
- }
542
- const serverToolNames = [...new Set(unit.annotations.map((a) => a.toolName))];
543
- const serverNames = [unit.serverName];
544
- const serverTools = unit.annotations.map((a) => ({ serverName: a.serverName, toolName: a.toolName }));
545
- let verifierSystem = this.cacheStrategy.wrapSystemPrompt(buildJudgeSystemPrompt(unit.constitutionText, serverPolicyFile, unit.protectedPaths, serverTools, dynamicLists, unit.allowedDirectory, unit.storedAnnotations));
546
- let verifierSession = new PolicyVerifierSession({
547
- system: verifierSystem,
548
- model: this.model,
549
- serverNames,
550
- toolNames: serverToolNames,
551
- storedAnnotations: unit.storedAnnotations,
552
- });
553
- const verifyLabel = ` ${unit.serverName}: Verifying`;
554
- const { result: verificationResultInitial } = await withSpinner(verifyLabel, async (spinner) => verifyPolicy(unit.constitutionText, serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, filteredScenarios, this.model, 3, unit.allowedDirectory, (msg) => {
555
- spinner.text = `${verifyLabel} — ${msg}`;
556
- }, serverDomainAllowlists, dynamicLists, verifierSystem, verifierSession, unit.storedAnnotations), (r, elapsed) => r.pass
557
- ? `${verifyLabel}: ${r.rounds.length} round(s) (${elapsed.toFixed(1)}s)`
558
- : `${verifyLabel}: completed with failures (${elapsed.toFixed(1)}s)`);
559
- let verificationResult = verificationResultInitial;
560
- if (!verificationResult.pass) {
561
- this.logVerboseFailures(verificationResult);
562
- }
563
- // Collect probe scenarios
564
- const { valid: filteredInitialProbes } = filterAndLogStructuralConflicts(filterEngine, collectProbeScenarios(verificationResult), 'Discarded probe (structural conflict)', unit.storedAnnotations);
565
- const accumulatedProbes = filteredInitialProbes;
566
- // Compile-verify-repair loop (up to 2 repair attempts)
567
- const MAX_REPAIRS = 2;
568
- let repairAttempts = 0;
569
- let currentFilteredScenarios;
570
- if (!verificationResult.pass) {
571
- const baseInputHash = inputHash;
572
- for (let attempt = 1; attempt <= MAX_REPAIRS; attempt++) {
573
- console.error('');
574
- const lastRound = verificationResult.rounds[verificationResult.rounds.length - 1];
575
- const judgeAnalysis = lastRound?.llmAnalysis ?? verificationResult.summary;
576
- const attributedFailures = lastRound?.attributedFailures ?? [];
577
- const allScenarios = [...scenarioResult.scenarios, ...accumulatedProbes];
578
- const { corrections, handwrittenWarnings } = extractScenarioCorrections(attributedFailures, allScenarios);
579
- for (const warning of handwrittenWarnings) {
580
- console.error(` ${chalk.yellow('Warning:')} ${warning}`);
581
- }
582
- if (corrections.length > 0) {
583
- scenarioResult.scenarios = applyScenarioCorrections(scenarioResult.scenarios, corrections);
584
- const correctedProbes = applyScenarioCorrections(accumulatedProbes, corrections);
585
- accumulatedProbes.splice(0, accumulatedProbes.length, ...correctedProbes);
586
- console.error(` ${chalk.dim(`Corrected ${corrections.length} scenario expectation(s)`)}`);
587
- }
588
- ({ valid: currentFilteredScenarios } = filterAndLogStructuralConflicts(filterEngine, scenarioResult.scenarios, undefined, unit.storedAnnotations));
589
- const allRuleBlamedFailures = verificationResult.failedScenarios.filter((f) => {
590
- const attr = attributedFailures.find((a) => a.scenarioDescription === f.scenario.description);
591
- if (!attr || attr.blame.kind === 'rule' || attr.blame.kind === 'both')
592
- return true;
593
- return handwrittenWarnings.some((w) => w.includes(f.scenario.description));
594
- });
595
- if (allRuleBlamedFailures.length > 0) {
596
- const repairContext = {
597
- failedScenarios: allRuleBlamedFailures,
598
- judgeAnalysis,
599
- attemptNumber: attempt,
600
- existingListDefinitions: listDefinitions.length > 0 ? listDefinitions : undefined,
601
- handwrittenScenarios: unit.handwrittenScenarios.length > 0 ? unit.handwrittenScenarios : undefined,
602
- };
603
- this.logContext.stepName = `repair-compile-${unit.serverName}-${attempt}`;
604
- const repairText = ` ${unit.serverName} repair ${attempt}/${MAX_REPAIRS}: Recompiling`;
605
- const { result: repairResult } = await withSpinner(repairText, async (spinner) => this.compilePolicyRulesWithPointFix(rules, unit.annotations, unit.protectedPaths, baseInputHash, repairContext, compilerSystem, compilerSession, listDefinitions, (msg) => {
606
- spinner.text = `${repairText} — ${msg}`;
607
- }), (r, elapsed) => `${repairText}: ${r.rules.length} rules (${elapsed.toFixed(1)}s)`);
608
- rules = repairResult.rules;
609
- listDefinitions = repairResult.listDefinitions;
610
- compilerSession = repairResult.session;
611
- // Re-validate server scoping after repair
612
- validateServerScoping(unit.serverName, rules);
613
- // Re-resolve dynamic lists if repair changed list definitions
614
- if (listDefinitions.length > 0) {
615
- dynamicLists = await this.resolveServerLists(listDefinitions, serverOutputDir, config, ` ${unit.serverName}`);
616
- }
617
- serverPolicyFile = buildPolicyArtifact(constitutionHash, rules, listDefinitions, inputHash);
618
- // Persist repaired rules immediately
619
- writeServerPolicy();
620
- verifierSystem = this.cacheStrategy.wrapSystemPrompt(buildJudgeSystemPrompt(unit.constitutionText, serverPolicyFile, unit.protectedPaths, serverTools, dynamicLists, unit.allowedDirectory, unit.storedAnnotations));
621
- verifierSession = new PolicyVerifierSession({
622
- system: verifierSystem,
623
- model: this.model,
624
- serverNames,
625
- toolNames: serverToolNames,
626
- storedAnnotations: unit.storedAnnotations,
627
- });
628
- }
629
- else {
630
- console.error(` ${chalk.dim('No rule-blamed failures — skipping recompilation')}`);
631
- }
632
- this.logContext.stepName = `repair-verify-${unit.serverName}-${attempt}`;
633
- const scenariosForRepairVerify = [...currentFilteredScenarios, ...accumulatedProbes];
634
- const repairVerifyText = ` ${unit.serverName} repair ${attempt}/${MAX_REPAIRS}: Verifying`;
635
- const { result: repairVerifyResult } = await withSpinner(repairVerifyText, async (spinner) => verifyPolicy(unit.constitutionText, serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, scenariosForRepairVerify, this.model, 1, unit.allowedDirectory, (msg) => {
636
- spinner.text = `${repairVerifyText} — ${msg}`;
637
- }, serverDomainAllowlists, dynamicLists, verifierSystem, verifierSession, unit.storedAnnotations), (r, elapsed) => r.pass
638
- ? `${repairVerifyText}: passed (${elapsed.toFixed(1)}s)`
639
- : `${repairVerifyText}: ${r.failedScenarios.length} failure(s) (${elapsed.toFixed(1)}s)`);
640
- verificationResult = repairVerifyResult;
641
- if (!verificationResult.pass) {
642
- this.logVerboseFailures(verificationResult);
643
- }
644
- const { valid: validRepairProbes } = filterAndLogStructuralConflicts(filterEngine, collectProbeScenarios(verificationResult), 'Discarded probe (structural conflict)', unit.storedAnnotations);
645
- accumulatedProbes.push(...validRepairProbes);
646
- repairAttempts = attempt;
647
- if (verificationResult.pass) {
648
- break;
649
- }
650
- }
651
- }
635
+ const scenarioResult = await this.generateTestScenarios(unit.constitutionText, unit.annotations, unit.allowedDirectory, unit.handwrittenScenarios, scenarioHash, existingServerScenarios, ` ${unit.serverName}`, permittedDirectories, dynamicLists, unit.storedAnnotations, model, reporter);
636
+ // Step 3: Verify and repair
637
+ const state = await this.verifyAndRepairServer(unit, config, constitutionHash, inputHash, serverPolicyFile, serverAnnotationsFile, serverDomainAllowlists, dynamicLists, rules, listDefinitions, compileResult.session, scenarioResult, permittedDirectories, compilerSystem, model, logContext, reporter, serverOutputDir, writeServerPolicy);
638
+ // Sync repaired state back so the writeServerPolicy closure sees updated values
639
+ rules = state.rules;
640
+ listDefinitions = state.listDefinitions;
652
641
  // Write per-server artifacts (before verification check so they can be inspected on failure)
653
- const finalScenarios = [...scenarioResult.scenarios, ...accumulatedProbes];
642
+ const finalScenarios = [...scenarioResult.scenarios, ...state.accumulatedProbes];
654
643
  writeServerPolicy();
655
644
  writeArtifact(serverOutputDir, 'test-scenarios.json', {
656
645
  generatedAt: new Date().toISOString(),
@@ -658,44 +647,205 @@ export class PipelineRunner {
658
647
  inputHash: scenarioResult.inputHash,
659
648
  scenarios: finalScenarios,
660
649
  });
661
- if (!verificationResult.pass) {
650
+ if (!state.verificationResult.pass) {
662
651
  throw new Error(`Verification FAILED for server "${unit.serverName}" — artifacts written for inspection but policy may need review.`);
663
652
  }
664
- console.error(` ${chalk.green(unit.serverName)}: ${rules.length} rules, ${finalScenarios.length} scenarios` +
665
- (repairAttempts > 0 ? `, ${repairAttempts} repair(s)` : ''));
653
+ const summary = `${state.rules.length} rules, ${finalScenarios.length} scenarios` +
654
+ (state.repairAttempts > 0 ? `, ${state.repairAttempts} repair(s)` : '');
655
+ reporter.done(summary);
666
656
  return {
667
657
  serverName: unit.serverName,
668
- rules,
669
- listDefinitions,
658
+ rules: state.rules,
659
+ listDefinitions: state.listDefinitions,
670
660
  scenarios: finalScenarios,
671
661
  inputHash,
672
662
  constitutionHash,
673
- resolvedLists: dynamicLists,
663
+ resolvedLists: state.dynamicLists,
664
+ };
665
+ }
666
+ /**
667
+ * Runs initial verification and the compile-verify-repair loop for a server.
668
+ * Returns the mutable compilation state after all repair attempts.
669
+ */
670
+ async verifyAndRepairServer(unit, config, constitutionHash, inputHash, serverPolicyFile, serverAnnotationsFile, serverDomainAllowlists, dynamicLists, rules, listDefinitions, compilerSession, scenarioResult, permittedDirectories, compilerSystem, model, logContext, reporter, serverOutputDir, writeServerPolicy) {
671
+ logContext.stepName = `verify-${unit.serverName}`;
672
+ const filterEngine = new PolicyEngine(serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, unit.allowedDirectory, undefined, dynamicLists);
673
+ const { filteredScenarios } = await this.filterAndRepairDiscardedScenarios(filterEngine, scenarioResult, unit, permittedDirectories, dynamicLists, model, logContext, reporter);
674
+ const serverToolNames = [...new Set(unit.annotations.map((a) => a.toolName))];
675
+ const serverNames = [unit.serverName];
676
+ const serverTools = unit.annotations.map((a) => ({ serverName: a.serverName, toolName: a.toolName }));
677
+ const verifierSystem = this.cacheStrategy.wrapSystemPrompt(buildJudgeSystemPrompt(unit.constitutionText, serverPolicyFile, unit.protectedPaths, serverTools, dynamicLists, unit.allowedDirectory, unit.storedAnnotations));
678
+ const verifierSession = new PolicyVerifierSession({
679
+ system: verifierSystem,
680
+ model,
681
+ serverNames,
682
+ toolNames: serverToolNames,
683
+ storedAnnotations: unit.storedAnnotations,
684
+ });
685
+ reporter.update('verifying');
686
+ const verifyStart = Date.now();
687
+ const verificationResultInitial = await verifyPolicy(unit.constitutionText, serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, filteredScenarios, model, 3, unit.allowedDirectory, (msg) => reporter.update('verifying', msg), serverDomainAllowlists, dynamicLists, verifierSystem, verifierSession, unit.storedAnnotations);
688
+ const verifyElapsed = (Date.now() - verifyStart) / 1000;
689
+ const verifyLabel = ` ${unit.serverName}: Verifying`;
690
+ reporter.complete('verifying', verificationResultInitial.pass
691
+ ? `${verifyLabel}: ${verificationResultInitial.rounds.length} round(s) (${verifyElapsed.toFixed(1)}s)`
692
+ : `${verifyLabel}: completed with failures (${verifyElapsed.toFixed(1)}s)`, verifyElapsed);
693
+ if (!verificationResultInitial.pass) {
694
+ this.logVerboseFailures(verificationResultInitial, reporter);
695
+ }
696
+ // Collect probe scenarios
697
+ const { valid: filteredInitialProbes } = filterAndLogStructuralConflicts(filterEngine, collectProbeScenarios(verificationResultInitial), 'Discarded probe (structural conflict)', unit.storedAnnotations, reporter);
698
+ const state = {
699
+ rules,
700
+ listDefinitions,
701
+ compilerSession,
702
+ dynamicLists,
703
+ serverPolicyFile,
704
+ verificationResult: verificationResultInitial,
705
+ accumulatedProbes: filteredInitialProbes,
706
+ repairAttempts: 0,
674
707
  };
708
+ if (!state.verificationResult.pass) {
709
+ await this.runRepairLoop(state, unit, config, constitutionHash, inputHash, serverAnnotationsFile, serverDomainAllowlists, scenarioResult, filterEngine, serverToolNames, serverNames, serverTools, compilerSystem, model, logContext, reporter, serverOutputDir, writeServerPolicy);
710
+ }
711
+ return state;
712
+ }
713
+ /**
714
+ * Filters structurally conflicting scenarios and generates replacements
715
+ * for discarded ones via LLM repair (pre-loop, one-time).
716
+ */
717
+ async filterAndRepairDiscardedScenarios(filterEngine, scenarioResult, unit, permittedDirectories, dynamicLists, model, logContext, reporter) {
718
+ const { valid: filteredScenarios, discarded: discardedScenarios } = filterAndLogStructuralConflicts(filterEngine, scenarioResult.scenarios, undefined, unit.storedAnnotations, reporter);
719
+ if (discardedScenarios.length > 0) {
720
+ const discardedForRepair = discardedScenarios.map((d) => ({
721
+ scenario: d.scenario,
722
+ feedback: `${d.rule} always returns ${d.actual}`,
723
+ }));
724
+ logContext.stepName = `repair-scenarios-${unit.serverName}`;
725
+ reporter.update('repair-scenarios');
726
+ const repairStart = Date.now();
727
+ const replacementScenarios = await repairScenarios(discardedForRepair, unit.constitutionText, unit.annotations, unit.allowedDirectory, model, permittedDirectories, dynamicLists, (msg) => reporter.warn(` ${chalk.dim(msg)}`), unit.storedAnnotations);
728
+ if (replacementScenarios.length > 0) {
729
+ const { valid: validReplacements } = filterAndLogStructuralConflicts(filterEngine, replacementScenarios, 'Discarded replacement (structural conflict)', unit.storedAnnotations, reporter);
730
+ scenarioResult.scenarios.push(...validReplacements);
731
+ filteredScenarios.push(...validReplacements);
732
+ reporter.warn(` ${chalk.dim(`Repaired ${discardedScenarios.length} discarded scenario(s) → ${validReplacements.length} replacement(s)`)}`);
733
+ }
734
+ const repairElapsed = (Date.now() - repairStart) / 1000;
735
+ reporter.complete('repair-scenarios', ` ${unit.serverName}: Repaired scenarios (${repairElapsed.toFixed(1)}s)`, repairElapsed);
736
+ }
737
+ return { filteredScenarios };
738
+ }
739
+ /**
740
+ * Compile-verify-repair loop (up to 2 repair attempts).
741
+ * Mutates `state` in place with updated rules, lists, and verification results.
742
+ */
743
+ async runRepairLoop(state, unit, config, constitutionHash, inputHash, serverAnnotationsFile, serverDomainAllowlists, scenarioResult, filterEngine, serverToolNames, serverNames, serverTools, compilerSystem, model, logContext, reporter, serverOutputDir, writeServerPolicy) {
744
+ const MAX_REPAIRS = 2;
745
+ for (let attempt = 1; attempt <= MAX_REPAIRS; attempt++) {
746
+ const lastRound = state.verificationResult.rounds[state.verificationResult.rounds.length - 1];
747
+ const judgeAnalysis = lastRound?.llmAnalysis ?? state.verificationResult.summary;
748
+ const attributedFailures = lastRound?.attributedFailures ?? [];
749
+ const allScenarios = [...scenarioResult.scenarios, ...state.accumulatedProbes];
750
+ const { corrections, handwrittenWarnings } = extractScenarioCorrections(attributedFailures, allScenarios);
751
+ for (const warning of handwrittenWarnings) {
752
+ reporter.warn(` ${chalk.yellow('Warning:')} ${warning}`);
753
+ }
754
+ if (corrections.length > 0) {
755
+ scenarioResult.scenarios = applyScenarioCorrections(scenarioResult.scenarios, corrections);
756
+ const correctedProbes = applyScenarioCorrections(state.accumulatedProbes, corrections);
757
+ state.accumulatedProbes.splice(0, state.accumulatedProbes.length, ...correctedProbes);
758
+ reporter.warn(` ${chalk.dim(`Corrected ${corrections.length} scenario expectation(s)`)}`);
759
+ }
760
+ const { valid: currentFilteredScenarios } = filterAndLogStructuralConflicts(filterEngine, scenarioResult.scenarios, undefined, unit.storedAnnotations, reporter);
761
+ const allRuleBlamedFailures = state.verificationResult.failedScenarios.filter((f) => {
762
+ const attr = attributedFailures.find((a) => a.scenarioDescription === f.scenario.description);
763
+ if (!attr || attr.blame.kind === 'rule' || attr.blame.kind === 'both')
764
+ return true;
765
+ return handwrittenWarnings.some((w) => w.includes(f.scenario.description));
766
+ });
767
+ if (allRuleBlamedFailures.length > 0) {
768
+ const repairContext = {
769
+ failedScenarios: allRuleBlamedFailures,
770
+ judgeAnalysis,
771
+ attemptNumber: attempt,
772
+ existingListDefinitions: state.listDefinitions.length > 0 ? state.listDefinitions : undefined,
773
+ handwrittenScenarios: unit.handwrittenScenarios.length > 0 ? unit.handwrittenScenarios : undefined,
774
+ };
775
+ logContext.stepName = `repair-compile-${unit.serverName}-${attempt}`;
776
+ reporter.update('repair-compile', `attempt ${attempt}/${MAX_REPAIRS}`);
777
+ const repairCompileStart = Date.now();
778
+ const repairResult = await this.compilePolicyRulesWithPointFix(state.rules, unit.annotations, unit.protectedPaths, inputHash, repairContext, compilerSystem, state.compilerSession, state.listDefinitions, model, (msg) => reporter.update('repair-compile', msg), reporter);
779
+ const repairCompileElapsed = (Date.now() - repairCompileStart) / 1000;
780
+ reporter.complete('repair-compile', ` ${unit.serverName} repair ${attempt}/${MAX_REPAIRS}: ${repairResult.rules.length} rules (${repairCompileElapsed.toFixed(1)}s)`, repairCompileElapsed);
781
+ state.rules = repairResult.rules;
782
+ state.listDefinitions = repairResult.listDefinitions;
783
+ state.compilerSession = repairResult.session;
784
+ // Re-validate server scoping after repair
785
+ validateServerScoping(unit.serverName, state.rules);
786
+ // Re-resolve dynamic lists if repair changed list definitions
787
+ if (state.listDefinitions.length > 0) {
788
+ state.dynamicLists = await this.resolveServerLists(state.listDefinitions, serverOutputDir, config, ` ${unit.serverName}`, model, logContext, reporter);
789
+ }
790
+ state.serverPolicyFile = buildPolicyArtifact(constitutionHash, state.rules, state.listDefinitions, inputHash);
791
+ // Persist repaired rules immediately
792
+ writeServerPolicy();
793
+ }
794
+ else {
795
+ reporter.warn(` ${chalk.dim('No rule-blamed failures — skipping recompilation')}`);
796
+ }
797
+ // Build verifier from current state (may have been updated by repair above)
798
+ const verifierSystem = this.cacheStrategy.wrapSystemPrompt(buildJudgeSystemPrompt(unit.constitutionText, state.serverPolicyFile, unit.protectedPaths, serverTools, state.dynamicLists, unit.allowedDirectory, unit.storedAnnotations));
799
+ const verifierSession = new PolicyVerifierSession({
800
+ system: verifierSystem,
801
+ model,
802
+ serverNames,
803
+ toolNames: serverToolNames,
804
+ storedAnnotations: unit.storedAnnotations,
805
+ });
806
+ logContext.stepName = `repair-verify-${unit.serverName}-${attempt}`;
807
+ const scenariosForRepairVerify = [...currentFilteredScenarios, ...state.accumulatedProbes];
808
+ reporter.update('repair-verify', `attempt ${attempt}/${MAX_REPAIRS}`);
809
+ const repairVerifyStart = Date.now();
810
+ const repairVerifyResult = await verifyPolicy(unit.constitutionText, state.serverPolicyFile, serverAnnotationsFile, unit.protectedPaths, scenariosForRepairVerify, model, 1, unit.allowedDirectory, (msg) => reporter.update('repair-verify', msg), serverDomainAllowlists, state.dynamicLists, verifierSystem, verifierSession, unit.storedAnnotations);
811
+ const repairVerifyElapsed = (Date.now() - repairVerifyStart) / 1000;
812
+ const repairVerifyText = ` ${unit.serverName} repair ${attempt}/${MAX_REPAIRS}: Verifying`;
813
+ reporter.complete('repair-verify', repairVerifyResult.pass
814
+ ? `${repairVerifyText}: passed (${repairVerifyElapsed.toFixed(1)}s)`
815
+ : `${repairVerifyText}: ${repairVerifyResult.failedScenarios.length} failure(s) (${repairVerifyElapsed.toFixed(1)}s)`, repairVerifyElapsed);
816
+ state.verificationResult = repairVerifyResult;
817
+ if (!state.verificationResult.pass) {
818
+ this.logVerboseFailures(state.verificationResult, reporter);
819
+ }
820
+ const { valid: validRepairProbes } = filterAndLogStructuralConflicts(filterEngine, collectProbeScenarios(state.verificationResult), 'Discarded probe (structural conflict)', unit.storedAnnotations, reporter);
821
+ state.accumulatedProbes.push(...validRepairProbes);
822
+ state.repairAttempts = attempt;
823
+ if (state.verificationResult.pass) {
824
+ break;
825
+ }
826
+ }
675
827
  }
676
828
  /**
677
829
  * Compiles policy rules for a single server (initial compilation).
678
830
  * Uses per-server schema with requireServer: true.
679
831
  */
680
- async compileServerPolicyRules(unit, system, inputHash) {
681
- const stepText = ` ${unit.serverName}: Compiling rules`;
832
+ async compileServerPolicyRules(unit, system, inputHash, model, reporter) {
833
+ reporter.update('compiling');
834
+ const start = Date.now();
682
835
  const session = new ConstitutionCompilerSession({
683
836
  system,
684
- model: this.model,
837
+ model,
685
838
  annotations: unit.annotations,
686
839
  schemaOptions: { requireServer: true },
687
840
  });
688
- const { result: compilationOutput } = await withSpinner(stepText, async (spinner) => {
689
- const output = await session.compile((msg) => {
690
- spinner.text = `${stepText} — ${msg}`;
691
- });
692
- const compiledRules = resolveRulePaths(output.rules);
693
- validateRulesOrThrow(compiledRules, output.listDefinitions);
694
- return { rules: compiledRules, listDefinitions: output.listDefinitions };
695
- }, (output, elapsed) => `${stepText}: ${output.rules.length} rules (${elapsed.toFixed(1)}s)`);
841
+ const output = await session.compile((msg) => reporter.update('compiling', msg));
842
+ const compiledRules = resolveRulePaths(output.rules);
843
+ validateRulesOrThrow(compiledRules, output.listDefinitions, reporter);
844
+ const elapsed = (Date.now() - start) / 1000;
845
+ reporter.complete('compiling', ` ${unit.serverName}: Compiling rules: ${compiledRules.length} rules (${elapsed.toFixed(1)}s)`, elapsed);
696
846
  return {
697
- rules: compilationOutput.rules,
698
- listDefinitions: compilationOutput.listDefinitions,
847
+ rules: compiledRules,
848
+ listDefinitions: output.listDefinitions,
699
849
  inputHash,
700
850
  session,
701
851
  };
@@ -703,7 +853,7 @@ export class PipelineRunner {
703
853
  // -----------------------------------------------------------------------
704
854
  // Private compilation methods
705
855
  // -----------------------------------------------------------------------
706
- async compilePolicyRulesWithRepair(annotations, protectedPaths, baseInputHash, repairContext, system, session, onProgress) {
856
+ async compilePolicyRulesWithRepair(annotations, protectedPaths, baseInputHash, repairContext, system, session, model, onProgress, reporter) {
707
857
  let output;
708
858
  if (session) {
709
859
  output = await session.recompile(repairContext, onProgress);
@@ -711,10 +861,10 @@ export class PipelineRunner {
711
861
  else {
712
862
  const { compileConstitution } = await import('./constitution-compiler.js');
713
863
  output = await compileConstitution('', // Not needed when system prompt is provided
714
- annotations, { protectedPaths }, this.model, repairContext, onProgress, system);
864
+ annotations, { protectedPaths }, model, repairContext, onProgress, system);
715
865
  }
716
866
  const compiledRules = resolveRulePaths(output.rules);
717
- validateRulesOrThrow(compiledRules, output.listDefinitions);
867
+ validateRulesOrThrow(compiledRules, output.listDefinitions, reporter);
718
868
  return {
719
869
  rules: compiledRules,
720
870
  listDefinitions: output.listDefinitions,
@@ -728,17 +878,17 @@ export class PipelineRunner {
728
878
  * patch instead of regenerating the entire rule set. This avoids oscillation
729
879
  * where fixing one failure breaks previously-passing rules.
730
880
  */
731
- async compilePolicyRulesWithPointFix(existingRules, annotations, protectedPaths, baseInputHash, repairContext, system, session, existingListDefinitions, onProgress) {
881
+ async compilePolicyRulesWithPointFix(existingRules, annotations, protectedPaths, baseInputHash, repairContext, system, session, existingListDefinitions, model, onProgress, reporter) {
732
882
  let output;
733
883
  if (session) {
734
884
  output = await session.repairPointFix(existingRules, repairContext, existingListDefinitions, onProgress);
735
885
  }
736
886
  else {
737
887
  // No session available -- fall back to full recompile
738
- return this.compilePolicyRulesWithRepair(annotations, protectedPaths, baseInputHash, repairContext, system, session, onProgress);
888
+ return this.compilePolicyRulesWithRepair(annotations, protectedPaths, baseInputHash, repairContext, system, session, model, onProgress, reporter);
739
889
  }
740
890
  const compiledRules = resolveRulePaths(output.rules);
741
- validateRulesOrThrow(compiledRules, output.listDefinitions);
891
+ validateRulesOrThrow(compiledRules, output.listDefinitions, reporter);
742
892
  return {
743
893
  rules: compiledRules,
744
894
  listDefinitions: output.listDefinitions,
@@ -751,25 +901,24 @@ export class PipelineRunner {
751
901
  * Loads existing per-server dynamic-lists.json for cache comparison,
752
902
  * connects MCP servers if needed, and writes results to serverOutputDir.
753
903
  */
754
- async resolveServerLists(listDefinitions, serverOutputDir, config, labelPrefix) {
755
- this.logContext.stepName = `resolve-lists-${labelPrefix.trim()}`;
904
+ async resolveServerLists(listDefinitions, serverOutputDir, config, labelPrefix, model, logContext, reporter) {
905
+ logContext.stepName = `resolve-lists-${labelPrefix.trim()}`;
756
906
  const existingLists = loadExistingArtifact(serverOutputDir, 'dynamic-lists.json');
757
907
  const needsMcp = listDefinitions.some((d) => d.requiresMcp);
758
908
  let proxy;
759
909
  if (needsMcp && config.mcpServers) {
760
910
  proxy = await connectViaProxy(listDefinitions, config.mcpServers, config.toolAnnotationsDir);
761
911
  }
762
- const listStepText = `${labelPrefix}: Resolving dynamic lists`;
912
+ reporter.update('lists');
913
+ const start = Date.now();
763
914
  try {
764
- const { result: resolvedLists } = await withSpinner(listStepText, async (spinner) => resolveAllLists(listDefinitions, {
765
- model: this.model,
915
+ const resolvedLists = await resolveAllLists(listDefinitions, {
916
+ model,
766
917
  proxyConnection: proxy ? { client: proxy.client, tools: proxy.tools } : undefined,
767
- }, existingLists, (msg) => {
768
- spinner.text = `${listStepText} ${msg}`;
769
- }), (result, elapsed) => {
770
- const count = Object.keys(result.lists).length;
771
- return `${listStepText}: ${count} list(s) resolved (${elapsed.toFixed(1)}s)`;
772
- });
918
+ }, existingLists, (msg) => reporter.update('lists', msg));
919
+ const elapsed = (Date.now() - start) / 1000;
920
+ const count = Object.keys(resolvedLists.lists).length;
921
+ reporter.complete('lists', `${labelPrefix}: Resolving dynamic lists: ${count} list(s) resolved (${elapsed.toFixed(1)}s)`, elapsed);
773
922
  writeArtifact(serverOutputDir, 'dynamic-lists.json', resolvedLists);
774
923
  return resolvedLists;
775
924
  }
@@ -779,28 +928,28 @@ export class PipelineRunner {
779
928
  }
780
929
  }
781
930
  }
782
- async generateTestScenarios(constitutionText, annotations, allowedDirectory, handwrittenScenarios, inputHash, existingScenarios, stepLabel, permittedDirectories, dynamicLists, storedAnnotations) {
931
+ async generateTestScenarios(constitutionText, annotations, allowedDirectory, handwrittenScenarios, inputHash, existingScenarios, stepLabel, permittedDirectories, dynamicLists, storedAnnotations, model, reporter) {
783
932
  const stepText = `${stepLabel} Generating test scenarios`;
784
933
  if (existingScenarios && existingScenarios.inputHash === inputHash) {
785
- showCached(stepText);
934
+ reporter.complete('scenarios', `${stepText} ${chalk.dim('(cached)')}`, 0);
786
935
  return { scenarios: existingScenarios.scenarios, inputHash };
787
936
  }
788
- const { result: scenarios } = await withSpinner(stepText, async (spinner) => generateScenarios(constitutionText, annotations, handwrittenScenarios, allowedDirectory, this.model, permittedDirectories, (msg) => {
789
- spinner.text = `${stepText} — ${msg}`;
790
- }, dynamicLists, (prompt) => this.cacheStrategy.wrapSystemPrompt(prompt), storedAnnotations), (scenarios, elapsed) => {
791
- const generatedCount = scenarios.length - handwrittenScenarios.length;
792
- return `${stepText}: ${scenarios.length} scenarios (${handwrittenScenarios.length} handwritten + ${generatedCount} generated) (${elapsed.toFixed(1)}s)`;
793
- });
937
+ reporter.update('scenarios');
938
+ const start = Date.now();
939
+ const scenarios = await generateScenarios(constitutionText, annotations, handwrittenScenarios, allowedDirectory, model, permittedDirectories, (msg) => reporter.update('scenarios', msg), dynamicLists, (prompt) => this.cacheStrategy.wrapSystemPrompt(prompt), storedAnnotations);
940
+ const elapsed = (Date.now() - start) / 1000;
941
+ const generatedCount = scenarios.length - handwrittenScenarios.length;
942
+ reporter.complete('scenarios', `${stepText}: ${scenarios.length} scenarios (${handwrittenScenarios.length} handwritten + ${generatedCount} generated) (${elapsed.toFixed(1)}s)`, elapsed);
794
943
  return { scenarios, inputHash };
795
944
  }
796
- logVerboseFailures(result) {
797
- console.error('');
798
- console.error(chalk.red('Verification FAILED:'));
799
- console.error(result.summary);
800
- console.error('');
945
+ logVerboseFailures(result, reporter) {
946
+ reporter.warn('');
947
+ reporter.warn(chalk.red('Verification FAILED:'));
948
+ reporter.warn(result.summary);
949
+ reporter.warn('');
801
950
  for (const f of result.failedScenarios) {
802
- console.error(` ${chalk.red('FAIL:')} ${f.scenario.description}`);
803
- console.error(` Expected: ${f.scenario.expectedDecision}, Got: ${f.actualDecision} (rule: ${f.matchingRule})`);
951
+ reporter.warn(` ${chalk.red('FAIL:')} ${f.scenario.description}`);
952
+ reporter.warn(` Expected: ${f.scenario.expectedDecision}, Got: ${f.actualDecision} (rule: ${f.matchingRule})`);
804
953
  }
805
954
  }
806
955
  }