imprint-mcp 0.6.1 → 0.6.3

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.
@@ -17,10 +17,15 @@ import { type ChildProcess, spawn } from 'node:child_process';
17
17
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join as pathJoin } from 'node:path';
19
19
  import { z } from 'zod';
20
+ import {
21
+ type AuthVerificationReceiptStatus,
22
+ readAuthVerificationReceiptStatus,
23
+ } from './auth-compile-tools.ts';
20
24
  import { type ProviderName, preferredAgentModel } from './llm.ts';
21
25
  import { createLog } from './log.ts';
22
26
  import { imprintHomeDir } from './paths.ts';
23
- import { discoverTools } from './tool-loader.ts';
27
+ import { type TeachState, loadTeachState } from './teach-state.ts';
28
+ import { type ResolvedTool, discoverTools } from './tool-loader.ts';
24
29
  import { llmSpanAttributes, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
25
30
 
26
31
  const log = createLog('audit');
@@ -94,6 +99,191 @@ interface AuditScore {
94
99
  verdict: 'pass' | 'fail' | 'inconclusive' | 'timeout';
95
100
  }
96
101
 
102
+ interface ExpectedAuditTool {
103
+ name: string;
104
+ parameters: readonly string[];
105
+ }
106
+
107
+ interface MissingAuditedParameter {
108
+ tool: string;
109
+ name: string;
110
+ }
111
+
112
+ interface AuditEvaluation {
113
+ score: AuditScore;
114
+ missingTools: string[];
115
+ missingParameters: MissingAuditedParameter[];
116
+ }
117
+
118
+ interface SkippedInteractiveAuth {
119
+ name: string;
120
+ reason: string;
121
+ verificationStatus: AuthVerificationReceiptStatus['status'];
122
+ verificationReason: string;
123
+ verificationAction?: string;
124
+ verificationBackend?: string;
125
+ verificationTimestamp?: number;
126
+ }
127
+
128
+ interface AuditToolPartition {
129
+ eligible: ResolvedTool[];
130
+ nonInteractiveAuthNames: string[];
131
+ skippedInteractiveAuth: SkippedInteractiveAuth[];
132
+ }
133
+
134
+ interface DetectedButUnemittedTool {
135
+ name: string;
136
+ reason?: string;
137
+ lastCompletedStep?: string;
138
+ }
139
+
140
+ /** Report candidates from the latest retained recording that teach detected but
141
+ * did not emit. They are diagnostic metadata, not part of the audit score: the
142
+ * audit agent can only call emitted MCP tools. */
143
+ export function detectedButUnemittedTools(
144
+ state: TeachState,
145
+ emittedToolNames: readonly string[],
146
+ ): DetectedButUnemittedTool[] {
147
+ const candidates = Object.values(state.workflows).filter((workflow) => workflow.candidate);
148
+ if (candidates.length === 0) return [];
149
+
150
+ const latest = candidates.reduce((current, workflow) =>
151
+ Date.parse(workflow.updatedAt) > Date.parse(current.updatedAt) ? workflow : current,
152
+ );
153
+ const emitted = new Set(emittedToolNames);
154
+ const byName = new Map<string, DetectedButUnemittedTool>();
155
+ for (const workflow of candidates) {
156
+ if (workflow.sessionPath !== latest.sessionPath || !workflow.candidate) continue;
157
+ const name = workflow.candidate.toolName;
158
+ // A same-named artifact may belong to an older successful teach. Only
159
+ // suppress the diagnostic when the latest recording itself reached emit
160
+ // successfully and the emitted artifact still exists.
161
+ if (emitted.has(name) && workflow.completedSteps.includes('emit') && !workflow.error) continue;
162
+ byName.set(name, {
163
+ name,
164
+ ...(workflow.error ? { reason: workflow.error.split('\n')[0] } : {}),
165
+ ...(workflow.completedSteps.at(-1)
166
+ ? { lastCompletedStep: workflow.completedSteps.at(-1) }
167
+ : {}),
168
+ });
169
+ }
170
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
171
+ }
172
+
173
+ export function countDetectedToolNames(
174
+ emittedToolNames: readonly string[],
175
+ detectedButUnemitted: readonly DetectedButUnemittedTool[],
176
+ ): number {
177
+ return new Set([...emittedToolNames, ...detectedButUnemitted.map((candidate) => candidate.name)])
178
+ .size;
179
+ }
180
+
181
+ /** Partition generated tools for a headless audit. Authentication is eligible
182
+ * only when every declared action completes directly: any pause means a human
183
+ * continuation (OTP, CAPTCHA, device approval, email link, etc.) may be needed. */
184
+ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPartition {
185
+ const eligible: ResolvedTool[] = [];
186
+ const nonInteractiveAuthNames: string[] = [];
187
+ const skippedInteractiveAuth: SkippedInteractiveAuth[] = [];
188
+
189
+ for (const tool of tools) {
190
+ // authConfig is the authoritative capability marker for legacy workflows
191
+ // where the newer optional toolKind field may be absent.
192
+ const auth = tool.workflow.authConfig;
193
+ if (tool.workflow.toolKind === 'authenticate' && !auth) {
194
+ const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
195
+ skippedInteractiveAuth.push({
196
+ name: tool.workflow.toolName,
197
+ reason: 'authentication configuration is missing, so unattended completion is not proven',
198
+ verificationStatus: receipt.status,
199
+ verificationReason: receipt.reason,
200
+ ...(receipt.action ? { verificationAction: receipt.action } : {}),
201
+ ...(receipt.backend ? { verificationBackend: receipt.backend } : {}),
202
+ ...(receipt.timestamp ? { verificationTimestamp: receipt.timestamp } : {}),
203
+ });
204
+ continue;
205
+ }
206
+ const pauses = auth
207
+ ? Object.entries(auth.actions).filter(([, action]) => action.outcome.type === 'pause')
208
+ : [];
209
+ if (pauses.length === 0) {
210
+ eligible.push(tool);
211
+ if (auth) nonInteractiveAuthNames.push(tool.workflow.toolName);
212
+ continue;
213
+ }
214
+
215
+ const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
216
+ const pauseSummary = pauses
217
+ .map(([name, action]) =>
218
+ action.outcome.type === 'pause' ? `${name}: ${action.outcome.message}` : name,
219
+ )
220
+ .join('; ');
221
+ skippedInteractiveAuth.push({
222
+ name: tool.workflow.toolName,
223
+ reason: `requires human continuation (${pauseSummary})`,
224
+ verificationStatus: receipt.status,
225
+ verificationReason: receipt.reason,
226
+ ...(receipt.action ? { verificationAction: receipt.action } : {}),
227
+ ...(receipt.backend ? { verificationBackend: receipt.backend } : {}),
228
+ ...(receipt.timestamp ? { verificationTimestamp: receipt.timestamp } : {}),
229
+ });
230
+ }
231
+
232
+ return { eligible, nonInteractiveAuthNames, skippedInteractiveAuth };
233
+ }
234
+
235
+ /**
236
+ * Restrict an agent-authored report to the connected workflows before it is
237
+ * scored. Agent output is not a trusted inventory: it can repeat a tool or a
238
+ * parameter, or mention a tool that was never connected. Taking the first
239
+ * report for each expected name keeps the result deterministic and, more
240
+ * importantly, prevents repeated/unknown positive verdicts from inflating the
241
+ * acceptance score. Later duplicates remain available in the persisted raw
242
+ * report for diagnosis; they simply carry no grading weight.
243
+ */
244
+ function normalizeAuditReport(
245
+ report: AuditReport,
246
+ expectedTools: readonly ExpectedAuditTool[],
247
+ ): AuditReport {
248
+ const firstToolByName = new Map<string, AuditReport['tools'][number]>();
249
+ for (const tool of report.tools) {
250
+ if (!firstToolByName.has(tool.name)) firstToolByName.set(tool.name, tool);
251
+ }
252
+
253
+ const normalizedTools: AuditReport['tools'] = [];
254
+ const seenExpectedTools = new Set<string>();
255
+ for (const expected of expectedTools) {
256
+ if (seenExpectedTools.has(expected.name)) continue;
257
+ seenExpectedTools.add(expected.name);
258
+
259
+ const reported = firstToolByName.get(expected.name);
260
+ if (!reported) continue;
261
+
262
+ const firstParameterByName = new Map<
263
+ string,
264
+ AuditReport['tools'][number]['parameters'][number]
265
+ >();
266
+ for (const parameter of reported.parameters) {
267
+ if (!firstParameterByName.has(parameter.name)) {
268
+ firstParameterByName.set(parameter.name, parameter);
269
+ }
270
+ }
271
+
272
+ const parameters: AuditReport['tools'][number]['parameters'] = [];
273
+ const seenExpectedParameters = new Set<string>();
274
+ for (const name of expected.parameters) {
275
+ if (seenExpectedParameters.has(name)) continue;
276
+ seenExpectedParameters.add(name);
277
+ const parameter = firstParameterByName.get(name);
278
+ if (parameter) parameters.push(parameter);
279
+ }
280
+
281
+ normalizedTools.push({ ...reported, parameters });
282
+ }
283
+
284
+ return { ...report, tools: normalizedTools };
285
+ }
286
+
97
287
  /**
98
288
  * Pure, deterministic scoring over the model's verdicts.
99
289
  *
@@ -107,7 +297,7 @@ interface AuditScore {
107
297
  * - `score = 100 * correct / graded` (0 when nothing was gradeable).
108
298
  * - Verdict: no gradeable invocations → `inconclusive` (re-run / site blocked
109
299
  * us, not a code fail). Otherwise `pass` requires both `score >= minScore`
110
- * AND at least `max(2, gradeableTools)` gradeable invocations, where
300
+ * AND at least one gradeable unit per gradeable tool, where
111
301
  * `gradeableTools` is the number of tools that produced ≥1 gradeable
112
302
  * invocation. Scaling the signal floor to *gradeable* tools (not all tools)
113
303
  * means a tool the auditor can never exercise — e.g. one that needs an opaque
@@ -175,7 +365,7 @@ export function computeAuditScore(report: AuditReport, minScore: number): AuditS
175
365
  }
176
366
  const graded = correct + broken;
177
367
  const score = graded === 0 ? 0 : (100 * correct) / graded;
178
- const minGraded = Math.max(2, gradeableTools);
368
+ const minGraded = Math.max(1, gradeableTools);
179
369
  let verdict: AuditScore['verdict'];
180
370
  if (graded === 0) {
181
371
  verdict = 'inconclusive';
@@ -223,6 +413,82 @@ export function ungradeableToolNames(report: AuditReport): string[] {
223
413
  return report.tools.filter((t) => !toolHasGradeableAuditSignal(t)).map((t) => t.name);
224
414
  }
225
415
 
416
+ /** Connected tools for which the auditor reported no invocation attempt.
417
+ * Merely naming a tool (or reporting parameter verdicts) is not evidence that
418
+ * its core operation was called. This is distinct from an ungradeable tool: an
419
+ * explicit infra/bad_params invocation is useful attempt evidence, while zero
420
+ * invocation records gives us no evidence that the auditor called it at all. */
421
+ export function missingAuditedToolNames(
422
+ report: AuditReport,
423
+ expectedToolNames: readonly string[],
424
+ ): string[] {
425
+ const attemptedNames = new Set(
426
+ report.tools.filter((tool) => tool.invocations.length > 0).map((tool) => tool.name),
427
+ );
428
+ return expectedToolNames.filter((name) => !attemptedNames.has(name));
429
+ }
430
+
431
+ /** Advertised workflow parameters absent from the auditor's final report.
432
+ * Every explicit parameter verdict counts as coverage, including `untestable`:
433
+ * the latter is an honest result, while omission provides no evidence that the
434
+ * auditor considered the parameter at all. */
435
+ export function missingAuditedParameters(
436
+ report: AuditReport,
437
+ expectedTools: readonly ExpectedAuditTool[],
438
+ ): MissingAuditedParameter[] {
439
+ const reportedParameters = new Map<string, Set<string>>();
440
+ for (const tool of report.tools) {
441
+ const names = reportedParameters.get(tool.name) ?? new Set<string>();
442
+ for (const parameter of tool.parameters) names.add(parameter.name);
443
+ reportedParameters.set(tool.name, names);
444
+ }
445
+
446
+ const missing: MissingAuditedParameter[] = [];
447
+ for (const tool of expectedTools) {
448
+ const covered = reportedParameters.get(tool.name) ?? new Set<string>();
449
+ for (const name of tool.parameters) {
450
+ if (!covered.has(name)) missing.push({ tool: tool.name, name });
451
+ }
452
+ }
453
+ return missing;
454
+ }
455
+
456
+ /** Complete deterministic audit evaluation, including report coverage. A high
457
+ * score cannot pass when the auditor omitted a connected tool or advertised
458
+ * parameter; timeouts remain the highest-priority terminal verdict. */
459
+ export function evaluateAuditReport(
460
+ report: AuditReport,
461
+ minScore: number,
462
+ expectedTools: readonly ExpectedAuditTool[],
463
+ timedOut = false,
464
+ ): AuditEvaluation {
465
+ const normalizedReport = normalizeAuditReport(report, expectedTools);
466
+ const rawScore = computeAuditScore(normalizedReport, minScore);
467
+ const missingTools = missingAuditedToolNames(
468
+ normalizedReport,
469
+ expectedTools.map((tool) => tool.name),
470
+ );
471
+ const missingParameters = missingAuditedParameters(normalizedReport, expectedTools);
472
+
473
+ let verdict = rawScore.verdict;
474
+ if (timedOut) {
475
+ verdict = 'timeout';
476
+ } else if (
477
+ rawScore.verdict === 'pass' &&
478
+ (!auditHasCorrectSignal(normalizedReport) ||
479
+ missingTools.length > 0 ||
480
+ missingParameters.length > 0)
481
+ ) {
482
+ verdict = 'inconclusive';
483
+ }
484
+
485
+ return {
486
+ score: { ...rawScore, verdict },
487
+ missingTools,
488
+ missingParameters,
489
+ };
490
+ }
491
+
226
492
  /** Advertised parameters the auditor could not differentially test (opaque enum
227
493
  * with no constructible value, or a state-changing/bot-defended tool). Surfaced
228
494
  * so an unverifiable parameter is visible rather than silently passing. */
@@ -250,6 +516,21 @@ interface RunAuditOptions {
250
516
  json?: boolean;
251
517
  }
252
518
 
519
+ export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<string, string> {
520
+ return {
521
+ // Codex MCP server `env` entries are an explicit environment, so relying on
522
+ // the audit driver's inherited IMPRINT_HOME can make an isolated audit
523
+ // silently fall back to ~/.imprint and expose stale tools.
524
+ IMPRINT_HOME: assetRoot,
525
+ IMPRINT_AUDIT_PACING_MS: '5000',
526
+ IMPRINT_AUDIT_TOOL_DEADLINE_MS: '120000',
527
+ };
528
+ }
529
+
530
+ export function buildAuditMcpCommandArgs(site: string, toolNames: readonly string[]): string[] {
531
+ return ['run', CLI_PATH, 'mcp-server', site, '--include-tools', toolNames.join(',')];
532
+ }
533
+
253
534
  export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
254
535
  return await traced(
255
536
  'audit.session',
@@ -260,13 +541,27 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
260
541
  },
261
542
  async (span) => {
262
543
  const assetRoot = imprintHomeDir();
263
- const tools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
264
- const toolCount = tools.length;
544
+ const discoveredTools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
545
+ const toolCount = discoveredTools.length;
265
546
  if (toolCount === 0) {
266
547
  throw new Error(
267
548
  `No generated tool found for site "${opts.site}" — run \`imprint teach ${opts.site}\` first, then audit it.`,
268
549
  );
269
550
  }
551
+ const {
552
+ eligible: tools,
553
+ nonInteractiveAuthNames,
554
+ skippedInteractiveAuth,
555
+ } = partitionAuditTools(discoveredTools);
556
+ const auditedToolCount = tools.length;
557
+ const detectedButUnemitted = detectedButUnemittedTools(
558
+ loadTeachState(opts.site),
559
+ discoveredTools.map((tool) => tool.workflow.toolName),
560
+ );
561
+ const detectedToolCount = countDetectedToolNames(
562
+ discoveredTools.map((tool) => tool.workflow.toolName),
563
+ detectedButUnemitted,
564
+ );
270
565
 
271
566
  const provider = opts.provider ?? 'claude-cli';
272
567
  const model = opts.model ?? preferredAgentModel(provider);
@@ -278,14 +573,29 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
278
573
  );
279
574
  }
280
575
 
281
- const toolNames = tools.map((t) => t.workflow.toolName);
282
- log(`auditing ${toolCount} tool(s) for site "${opts.site}": ${toolNames.join(', ')}`);
576
+ const nonInteractiveAuthSet = new Set(nonInteractiveAuthNames);
577
+ const expectedTools = tools.map((tool) => ({
578
+ name: tool.workflow.toolName,
579
+ parameters: nonInteractiveAuthSet.has(tool.workflow.toolName)
580
+ ? []
581
+ : tool.workflow.parameters.map((parameter) => parameter.name),
582
+ }));
583
+ const toolNames = expectedTools.map((tool) => tool.name);
584
+ log(
585
+ `auditing ${auditedToolCount}/${toolCount} eligible tool(s) for site "${opts.site}": ${toolNames.join(', ') || '(none)'}`,
586
+ );
587
+ for (const skipped of skippedInteractiveAuth) {
588
+ log(
589
+ `skipping interactive auth ${skipped.name}: ${skipped.reason}; receipt=${skipped.verificationStatus}`,
590
+ );
591
+ }
283
592
 
284
593
  // Parameters that shipped live-unverified at compile time (Fix D). Tell the
285
594
  // auditor to probe them especially — these are the most likely to be broken
286
595
  // (the compile-time differential could not confirm their effect).
287
596
  const unverifiedParams: Array<{ tool: string; params: string[] }> = [];
288
597
  for (const t of tools) {
598
+ if (nonInteractiveAuthSet.has(t.workflow.toolName)) continue;
289
599
  const params = (t.workflow.parameters ?? [])
290
600
  .filter((p) => p.verified === false)
291
601
  .map((p) => p.name);
@@ -309,18 +619,23 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
309
619
  }
310
620
  }
311
621
 
312
- const drive = await driveAudit({
313
- site: opts.site,
314
- provider,
315
- model,
316
- timeoutMs,
317
- systemPromptPath,
318
- toolNames,
319
- unverifiedParams,
320
- tokenDeps,
321
- });
322
-
323
- const rawScore = computeAuditScore(drive.report, opts.minScore);
622
+ const drive =
623
+ auditedToolCount === 0
624
+ ? emptyDriveAuditResult()
625
+ : await driveAudit({
626
+ site: opts.site,
627
+ provider,
628
+ model,
629
+ timeoutMs,
630
+ systemPromptPath,
631
+ toolNames,
632
+ nonInteractiveAuthNames,
633
+ unverifiedParams,
634
+ tokenDeps,
635
+ });
636
+ if (auditedToolCount === 0) {
637
+ log('all discovered tools require human-interactive authentication; auditor not spawned');
638
+ }
324
639
 
325
640
  // Cross-reference compile-time live verification with the audit grade.
326
641
  // The downgrade rule's purpose is to surface "flying blind" runs —
@@ -331,7 +646,7 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
331
646
  // chained tool was unreachable from auditor's connected set).
332
647
  // v2: downgrade only if a flying-blind tool had infra invocations
333
648
  // → still over-attributed transient page-state to defects.
334
- // v3 (current): downgrade only when the audit produced ZERO
649
+ // v3: downgrade only when the audit produced ZERO
335
650
  // `correct` invocations across ALL tools. If even one
336
651
  // invocation graded correctly, that's positive evidence the
337
652
  // framework + runtime work for at least that tool — the
@@ -339,21 +654,32 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
339
654
  // signal. Tools that couldn't be exercised still surface via
340
655
  // `ungradeableTools` / `unverifiedAndUngradeable` for visibility
341
656
  // without spoiling a verdict the score honestly earned.
657
+ // v4: retain v3's treatment of tools explicitly reported as
658
+ // blocked, but require every connected tool to appear in the final
659
+ // report. An omitted tool has no audit evidence at all, so a report
660
+ // covering only a successful subset is inconclusive rather than a
661
+ // pass.
662
+ // v5: likewise require an explicit verdict for every
663
+ // advertised workflow parameter. `untestable` is valid coverage;
664
+ // silently omitting a parameter is not.
665
+ // v6 (current): a connected tool must have at least one invocation
666
+ // record. Merely naming it in the report or discussing its
667
+ // parameters is not evidence that its core operation was attempted;
668
+ // infra and bad_params invocations still count as honest attempts.
342
669
  const ungradeableNames = ungradeableToolNames(drive.report);
670
+ const evaluation = evaluateAuditReport(
671
+ drive.report,
672
+ opts.minScore,
673
+ expectedTools,
674
+ drive.timedOut,
675
+ );
676
+ const { missingTools: missingToolNames, missingParameters } = evaluation;
343
677
  const untestableParamList = untestableParams(drive.report);
344
678
  const unverifiedAndUngradeable = tools
345
679
  .filter((t) => t.workflow.liveVerified === false)
346
680
  .map((t) => t.workflow.toolName)
347
681
  .filter((name) => ungradeableNames.includes(name));
348
- const anyCorrectAcrossAudit = auditHasCorrectSignal(drive.report);
349
- let verdict = rawScore.verdict;
350
- // Timeout takes precedence over inconclusive downgrade.
351
- if (drive.timedOut) {
352
- verdict = 'timeout';
353
- } else if (rawScore.verdict === 'pass' && !anyCorrectAcrossAudit) {
354
- verdict = 'inconclusive';
355
- }
356
- const score: AuditScore = { ...rawScore, verdict };
682
+ const score = evaluation.score;
357
683
 
358
684
  // Persist the auditor transcript next to the report so a stuck/killed run
359
685
  // can be inspected after the fact.
@@ -390,8 +716,14 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
390
716
  'imprint.audit.params_broken': score.paramsBroken,
391
717
  'imprint.audit.params_untestable': score.paramsUntestable,
392
718
  'imprint.audit.tool_count': toolCount,
719
+ 'imprint.audit.audited_tool_count': auditedToolCount,
720
+ 'imprint.audit.detected_tool_count': detectedToolCount,
721
+ 'imprint.audit.detected_but_unemitted_count': detectedButUnemitted.length,
722
+ 'imprint.audit.skipped_interactive_auth_count': skippedInteractiveAuth.length,
393
723
  'imprint.audit.verdict': score.verdict,
394
724
  'imprint.audit.unverified_and_ungradeable_count': unverifiedAndUngradeable.length,
725
+ 'imprint.audit.missing_tool_count': missingToolNames.length,
726
+ 'imprint.audit.missing_parameter_count': missingParameters.length,
395
727
  'imprint.audit.timed_out': drive.timedOut,
396
728
  'imprint.audit.turns': drive.turns,
397
729
  ...(drive.totalCostUsd != null ? { 'imprint.audit.cost_usd': drive.totalCostUsd } : {}),
@@ -413,7 +745,16 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
413
745
  report: drive.report,
414
746
  site: opts.site,
415
747
  toolCount,
748
+ auditedToolCount,
749
+ detectedToolCount,
750
+ detectedButUnemittedTools: detectedButUnemitted,
751
+ skippedInteractiveAuth,
416
752
  ungradeableTools: ungradeableNames,
753
+ /** Connected tools without an invocation record. A partial audit
754
+ * cannot pass merely because the auditor named an uncalled tool. */
755
+ missingTools: missingToolNames,
756
+ /** Advertised workflow parameters absent from the auditor's report. */
757
+ missingParameters,
417
758
  /** Advertised parameters the auditor could not differentially test. */
418
759
  untestableParams: untestableParamList,
419
760
  /** Tools that shipped without live verification at compile time AND
@@ -445,7 +786,13 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
445
786
  transcriptPath,
446
787
  costUsd: drive.totalCostUsd,
447
788
  unverifiedAndUngradeable,
789
+ missingTools: missingToolNames,
790
+ missingParameters,
448
791
  report: drive.report,
792
+ auditedToolCount,
793
+ skippedInteractiveAuth,
794
+ detectedToolCount,
795
+ detectedButUnemittedTools: detectedButUnemitted,
449
796
  });
450
797
  }
451
798
 
@@ -484,6 +831,9 @@ interface DriveAuditOptions {
484
831
  timeoutMs: number;
485
832
  systemPromptPath: string;
486
833
  toolNames: string[];
834
+ /** Eligible password-only/direct-success auth tools. Interactive auth tools
835
+ * are filtered before the MCP session is created and never appear here. */
836
+ nonInteractiveAuthNames?: string[];
487
837
  /** Per-tool params that shipped live-unverified at compile time. */
488
838
  unverifiedParams: Array<{ tool: string; params: string[] }>;
489
839
  /** Producer→consumer token contracts (param.sourcedFrom) so the auditor chains. */
@@ -546,17 +896,18 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
546
896
  // with zero tools. The `imprint-audit-` prefix keeps the inline server unique.
547
897
  const serverName = `imprint-audit-${opts.site}`;
548
898
  const bunPath = process.execPath;
899
+ const mcpEnv = buildAuditMcpEnvironment();
549
900
  const mcpConfig = {
550
901
  mcpServers: {
551
902
  [serverName]: {
552
903
  command: bunPath,
553
- args: ['run', CLI_PATH, 'mcp-server', opts.site],
904
+ args: buildAuditMcpCommandArgs(opts.site, opts.toolNames),
554
905
  // Pace every audit tool call: the auditor now differentially probes
555
906
  // bot-defended idempotent reads (search/calendar) instead of bailing
556
907
  // after one call, so a deliberate inter-call delay keeps the probing
557
908
  // steady enough that the per-IP anti-bot defense isn't tripped. Only
558
909
  // the audit sets this; production mcp-server runs unpaced.
559
- env: { IMPRINT_AUDIT_PACING_MS: '5000', IMPRINT_AUDIT_TOOL_DEADLINE_MS: '120000' },
910
+ env: mcpEnv,
560
911
  },
561
912
  },
562
913
  };
@@ -652,7 +1003,8 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
652
1003
  async function driveAuditWithCodex(opts: DriveAuditOptions): Promise<DriveAuditResult> {
653
1004
  const serverName = `imprint-audit-${opts.site}`;
654
1005
  const bunPath = process.execPath;
655
- const mcpArgs = ['run', CLI_PATH, 'mcp-server', opts.site];
1006
+ const mcpArgs = buildAuditMcpCommandArgs(opts.site, opts.toolNames);
1007
+ const mcpEnv = buildAuditMcpEnvironment();
656
1008
 
657
1009
  const unverifiedNote = buildUnverifiedParamNote(opts.unverifiedParams);
658
1010
  const initialPrompt = `<system_instructions>
@@ -685,9 +1037,11 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
685
1037
  '-c',
686
1038
  `mcp_servers.${serverName}.tool_timeout_sec=300`,
687
1039
  '-c',
688
- `mcp_servers.${serverName}.env.IMPRINT_AUDIT_PACING_MS=${JSON.stringify('5000')}`,
1040
+ `mcp_servers.${serverName}.env.IMPRINT_HOME=${JSON.stringify(mcpEnv.IMPRINT_HOME)}`,
1041
+ '-c',
1042
+ `mcp_servers.${serverName}.env.IMPRINT_AUDIT_PACING_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_PACING_MS)}`,
689
1043
  '-c',
690
- `mcp_servers.${serverName}.env.IMPRINT_AUDIT_TOOL_DEADLINE_MS=${JSON.stringify('120000')}`,
1044
+ `mcp_servers.${serverName}.env.IMPRINT_AUDIT_TOOL_DEADLINE_MS=${JSON.stringify(mcpEnv.IMPRINT_AUDIT_TOOL_DEADLINE_MS)}`,
691
1045
  '-c',
692
1046
  'shell_environment_policy.inherit=all',
693
1047
  '-',
@@ -701,8 +1055,7 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
701
1055
  cwd: REPO_ROOT,
702
1056
  env: {
703
1057
  ...process.env,
704
- IMPRINT_AUDIT_PACING_MS: '5000',
705
- IMPRINT_AUDIT_TOOL_DEADLINE_MS: '120000',
1058
+ ...mcpEnv,
706
1059
  },
707
1060
  stdio: ['pipe', 'pipe', 'pipe'],
708
1061
  });
@@ -758,6 +1111,10 @@ export function buildUnverifiedParamNote(
758
1111
  }
759
1112
 
760
1113
  export function buildAuditInitialPrompt(opts: DriveAuditOptions, unverifiedNote: string): string {
1114
+ const nonInteractiveAuthNames = opts.nonInteractiveAuthNames ?? [];
1115
+ const authNote = nonInteractiveAuthNames.length
1116
+ ? `\n\nThe following authentication tool(s) are eligible because every action completes without a human pause: ${nonInteractiveAuthNames.join(', ')}. Call each exactly once with one safe baseline. Never differentially probe or retry authentication actions. If any DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not call an authentication tool in response.`
1117
+ : '\n\nNo authentication tool is eligible in this audit. If a DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not attempt to authenticate.';
761
1118
  return `Audit every MCP tool connected to you for the site "${opts.site}".
762
1119
 
763
1120
  There are ${opts.toolNames.length} connected tool(s). For each one: read its description and input schema, invoke it with a realistic parameter set, judge the result, and classify each invocation as correct | tool_broken | infra | bad_params per your system prompt.
@@ -766,7 +1123,7 @@ Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/bro
766
1123
 
767
1124
  Promo/coupon/voucher/discount-code parameters require a known valid code to prove a visible effect. If neither the schema nor another tool output gives you a valid code, classify that parameter as untestable; do not invent a random code and call an unchanged response no_op.
768
1125
 
769
- IMPORTANT: Call tools strictly sequentially — issue exactly one tool call, wait for its result, then issue the next. Never issue tool calls in parallel or batch them in one turn. Many target sites share an anti-bot defense across endpoints, so a parallel burst trips a site-wide rate-limit (HTTP 429) that then poisons every later call. If a call returns a 429 / rate-limit / anti-bot result, classify it \`infra\` and pause before the next call.${unverifiedNote}${buildTokenDepNote(opts.tokenDeps)}
1126
+ IMPORTANT: Call tools strictly sequentially — issue exactly one tool call, wait for its result, then issue the next. Never issue tool calls in parallel or batch them in one turn. Many target sites share an anti-bot defense across endpoints, so a parallel burst trips a site-wide rate-limit (HTTP 429) that then poisons every later call. If a call returns a 429 / rate-limit / anti-bot result, classify it \`infra\` and pause before the next call.${authNote}${unverifiedNote}${buildTokenDepNote(opts.tokenDeps)}
770
1127
 
771
1128
  When you are done, end your final message with exactly one fenced \`\`\`json block containing the full report and nothing after it.`;
772
1129
  }
@@ -1209,7 +1566,13 @@ function printSummary(
1209
1566
  transcriptPath?: string;
1210
1567
  costUsd?: number | null;
1211
1568
  unverifiedAndUngradeable: string[];
1569
+ missingTools: string[];
1570
+ missingParameters: MissingAuditedParameter[];
1212
1571
  report: AuditReport;
1572
+ auditedToolCount: number;
1573
+ skippedInteractiveAuth: SkippedInteractiveAuth[];
1574
+ detectedToolCount: number;
1575
+ detectedButUnemittedTools: DetectedButUnemittedTool[];
1213
1576
  },
1214
1577
  ): void {
1215
1578
  const pct = score.graded === 0 ? 'n/a' : `${score.score.toFixed(1)}%`;
@@ -1223,8 +1586,18 @@ function printSummary(
1223
1586
  const invGraded = score.graded - paramsTested;
1224
1587
  const invTotal = invGraded + score.infra + score.badParams;
1225
1588
  console.log(
1226
- `[imprint] graded ${score.graded} unit(s) = ${invGraded}/${invTotal} invocation(s) + ${paramsTested} parameter(s) across ${toolCount} tool(s) — excluded: ${score.infra} infra, ${score.badParams} bad_params, ${score.paramsUntestable} untestable param(s)`,
1589
+ `[imprint] graded ${score.graded} unit(s) = ${invGraded}/${invTotal} invocation(s) + ${paramsTested} parameter(s) across ${extra.auditedToolCount}/${toolCount} eligible tool(s) — excluded: ${score.infra} infra, ${score.badParams} bad_params, ${score.paramsUntestable} untestable param(s)`,
1227
1590
  );
1591
+ for (const skipped of extra.skippedInteractiveAuth) {
1592
+ console.log(
1593
+ `[imprint] skipped interactive auth ${skipped.name} — ${skipped.reason}; verification receipt: ${skipped.verificationStatus}`,
1594
+ );
1595
+ }
1596
+ if (extra.detectedButUnemittedTools.length > 0) {
1597
+ console.log(
1598
+ `[imprint] ${extra.detectedButUnemittedTools.length}/${extra.detectedToolCount} detected tool(s) were not emitted and are excluded from scoring: ${extra.detectedButUnemittedTools.map((tool) => tool.name).join(', ')}`,
1599
+ );
1600
+ }
1228
1601
  if (paramsTested + score.paramsUntestable > 0) {
1229
1602
  console.log(
1230
1603
  `[imprint] parameters: ${score.paramsWorking}/${paramsTested} working — ${score.paramsNoOp} no-op, ${score.paramsBroken} broken, ${score.paramsUntestable} untestable`,
@@ -1255,12 +1628,30 @@ function printSummary(
1255
1628
  `[imprint] ${extra.unverifiedAndUngradeable.length} tool(s) flying blind (no live verification at compile, no graded calls at audit): ${extra.unverifiedAndUngradeable.join(', ')}`,
1256
1629
  );
1257
1630
  }
1631
+ if (extra.missingTools.length > 0) {
1632
+ console.log(
1633
+ `[imprint] ${extra.missingTools.length} connected tool(s) have no invocation attempt in the auditor report: ${extra.missingTools.join(', ')}`,
1634
+ );
1635
+ }
1636
+ if (extra.missingParameters.length > 0) {
1637
+ console.log(
1638
+ `[imprint] ${extra.missingParameters.length} advertised parameter(s) missing from the auditor report: ${extra.missingParameters.map((parameter) => `${parameter.tool}.${parameter.name}`).join(', ')}`,
1639
+ );
1640
+ }
1258
1641
  if (score.verdict === 'timeout') {
1259
1642
  console.log(
1260
1643
  `[imprint] audit was killed at the ${formatDeadline(extra.timeoutMs)} deadline before finishing — partial results only. Re-run with a longer --timeout, or inspect the transcript to see where it stalled.`,
1261
1644
  );
1262
1645
  } else if (score.verdict === 'inconclusive') {
1263
- if (extra.unverifiedAndUngradeable.length > 0) {
1646
+ if (extra.missingTools.length > 0) {
1647
+ console.log(
1648
+ '[imprint] verdict downgraded to inconclusive because the auditor did not record an invocation attempt for every connected tool.',
1649
+ );
1650
+ } else if (extra.missingParameters.length > 0) {
1651
+ console.log(
1652
+ '[imprint] verdict downgraded to inconclusive because the auditor did not report every advertised parameter.',
1653
+ );
1654
+ } else if (extra.unverifiedAndUngradeable.length > 0) {
1264
1655
  console.log(
1265
1656
  '[imprint] verdict downgraded to inconclusive because at least one tool has zero live signal anywhere.',
1266
1657
  );