imprint-mcp 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +1 -1
- package/prompts/audit-agent.md +6 -5
- package/prompts/build-planning.md +2 -2
- package/prompts/compile-agent.md +6 -4
- package/prompts/live-verifier-agent.md +24 -11
- package/src/cli.ts +41 -6
- package/src/imprint/agent.ts +7 -2
- package/src/imprint/audit.ts +255 -42
- package/src/imprint/auth-compile-tools.ts +64 -13
- package/src/imprint/claude-cli-compile.ts +43 -7
- package/src/imprint/codex-cli-compile.ts +205 -129
- package/src/imprint/compile-agent-types.ts +25 -2
- package/src/imprint/compile-agent.ts +197 -40
- package/src/imprint/compile-tools.ts +164 -22
- package/src/imprint/inject-listener.ts +31 -3
- package/src/imprint/live-verifier.ts +167 -6
- package/src/imprint/llm.ts +7 -3
- package/src/imprint/mcp-compile-server.ts +188 -83
- package/src/imprint/mcp-server.ts +16 -1
- package/src/imprint/probe-backends.ts +60 -25
- package/src/imprint/react-flight-limits.ts +31 -0
- package/src/imprint/record.ts +657 -46
- package/src/imprint/redact.ts +240 -30
- package/src/imprint/response-body-intent.ts +73 -0
- package/src/imprint/response-body-lifecycle.ts +32 -0
- package/src/imprint/response-body-stream.ts +704 -0
- package/src/imprint/teach-state.ts +52 -2
- package/src/imprint/teach.ts +94 -69
- package/src/imprint/tracing.ts +60 -238
package/src/imprint/audit.ts
CHANGED
|
@@ -17,11 +17,16 @@ 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 {
|
|
24
|
-
import {
|
|
27
|
+
import { type TeachState, loadTeachState } from './teach-state.ts';
|
|
28
|
+
import { type ResolvedTool, discoverTools } from './tool-loader.ts';
|
|
29
|
+
import { recordLlmUsageSpan, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
|
|
25
30
|
|
|
26
31
|
const log = createLog('audit');
|
|
27
32
|
|
|
@@ -110,6 +115,123 @@ interface AuditEvaluation {
|
|
|
110
115
|
missingParameters: MissingAuditedParameter[];
|
|
111
116
|
}
|
|
112
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
|
+
|
|
113
235
|
/**
|
|
114
236
|
* Restrict an agent-authored report to the connected workflows before it is
|
|
115
237
|
* scored. Agent output is not a trusted inventory: it can repeat a tool or a
|
|
@@ -405,6 +527,24 @@ export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<s
|
|
|
405
527
|
};
|
|
406
528
|
}
|
|
407
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
|
+
|
|
534
|
+
/** Normalize provider usage to OpenInference's total prompt-token contract. */
|
|
535
|
+
export function auditPromptTokens(
|
|
536
|
+
provider: ProviderName,
|
|
537
|
+
inputTokens: number,
|
|
538
|
+
cacheReadTokens: number,
|
|
539
|
+
cacheWriteTokens: number,
|
|
540
|
+
): number {
|
|
541
|
+
// Codex reports input_tokens as the total and cached_input_tokens as a subset.
|
|
542
|
+
// Anthropic reports input_tokens as the uncached delta, with cache buckets
|
|
543
|
+
// alongside it, so only Anthropic usage needs summing.
|
|
544
|
+
if (provider === 'codex-cli') return inputTokens;
|
|
545
|
+
return totalPromptTokens(inputTokens, cacheReadTokens, cacheWriteTokens) ?? inputTokens;
|
|
546
|
+
}
|
|
547
|
+
|
|
408
548
|
export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
409
549
|
return await traced(
|
|
410
550
|
'audit.session',
|
|
@@ -415,13 +555,27 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
415
555
|
},
|
|
416
556
|
async (span) => {
|
|
417
557
|
const assetRoot = imprintHomeDir();
|
|
418
|
-
const
|
|
419
|
-
const toolCount =
|
|
558
|
+
const discoveredTools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
|
|
559
|
+
const toolCount = discoveredTools.length;
|
|
420
560
|
if (toolCount === 0) {
|
|
421
561
|
throw new Error(
|
|
422
562
|
`No generated tool found for site "${opts.site}" — run \`imprint teach ${opts.site}\` first, then audit it.`,
|
|
423
563
|
);
|
|
424
564
|
}
|
|
565
|
+
const {
|
|
566
|
+
eligible: tools,
|
|
567
|
+
nonInteractiveAuthNames,
|
|
568
|
+
skippedInteractiveAuth,
|
|
569
|
+
} = partitionAuditTools(discoveredTools);
|
|
570
|
+
const auditedToolCount = tools.length;
|
|
571
|
+
const detectedButUnemitted = detectedButUnemittedTools(
|
|
572
|
+
loadTeachState(opts.site),
|
|
573
|
+
discoveredTools.map((tool) => tool.workflow.toolName),
|
|
574
|
+
);
|
|
575
|
+
const detectedToolCount = countDetectedToolNames(
|
|
576
|
+
discoveredTools.map((tool) => tool.workflow.toolName),
|
|
577
|
+
detectedButUnemitted,
|
|
578
|
+
);
|
|
425
579
|
|
|
426
580
|
const provider = opts.provider ?? 'claude-cli';
|
|
427
581
|
const model = opts.model ?? preferredAgentModel(provider);
|
|
@@ -433,18 +587,29 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
433
587
|
);
|
|
434
588
|
}
|
|
435
589
|
|
|
590
|
+
const nonInteractiveAuthSet = new Set(nonInteractiveAuthNames);
|
|
436
591
|
const expectedTools = tools.map((tool) => ({
|
|
437
592
|
name: tool.workflow.toolName,
|
|
438
|
-
parameters: tool.workflow.
|
|
593
|
+
parameters: nonInteractiveAuthSet.has(tool.workflow.toolName)
|
|
594
|
+
? []
|
|
595
|
+
: tool.workflow.parameters.map((parameter) => parameter.name),
|
|
439
596
|
}));
|
|
440
597
|
const toolNames = expectedTools.map((tool) => tool.name);
|
|
441
|
-
log(
|
|
598
|
+
log(
|
|
599
|
+
`auditing ${auditedToolCount}/${toolCount} eligible tool(s) for site "${opts.site}": ${toolNames.join(', ') || '(none)'}`,
|
|
600
|
+
);
|
|
601
|
+
for (const skipped of skippedInteractiveAuth) {
|
|
602
|
+
log(
|
|
603
|
+
`skipping interactive auth ${skipped.name}: ${skipped.reason}; receipt=${skipped.verificationStatus}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
442
606
|
|
|
443
607
|
// Parameters that shipped live-unverified at compile time (Fix D). Tell the
|
|
444
608
|
// auditor to probe them especially — these are the most likely to be broken
|
|
445
609
|
// (the compile-time differential could not confirm their effect).
|
|
446
610
|
const unverifiedParams: Array<{ tool: string; params: string[] }> = [];
|
|
447
611
|
for (const t of tools) {
|
|
612
|
+
if (nonInteractiveAuthSet.has(t.workflow.toolName)) continue;
|
|
448
613
|
const params = (t.workflow.parameters ?? [])
|
|
449
614
|
.filter((p) => p.verified === false)
|
|
450
615
|
.map((p) => p.name);
|
|
@@ -468,16 +633,45 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
468
633
|
}
|
|
469
634
|
}
|
|
470
635
|
|
|
471
|
-
const drive =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
636
|
+
const drive =
|
|
637
|
+
auditedToolCount === 0
|
|
638
|
+
? emptyDriveAuditResult()
|
|
639
|
+
: await driveAudit({
|
|
640
|
+
site: opts.site,
|
|
641
|
+
provider,
|
|
642
|
+
model,
|
|
643
|
+
timeoutMs,
|
|
644
|
+
systemPromptPath,
|
|
645
|
+
toolNames,
|
|
646
|
+
nonInteractiveAuthNames,
|
|
647
|
+
unverifiedParams,
|
|
648
|
+
tokenDeps,
|
|
649
|
+
});
|
|
650
|
+
if (auditedToolCount === 0) {
|
|
651
|
+
log('all discovered tools require human-interactive authentication; auditor not spawned');
|
|
652
|
+
}
|
|
653
|
+
recordLlmUsageSpan(
|
|
654
|
+
'audit.llm_usage',
|
|
655
|
+
{
|
|
656
|
+
provider,
|
|
657
|
+
model,
|
|
658
|
+
inputTokens: auditPromptTokens(
|
|
659
|
+
provider,
|
|
660
|
+
drive.inputTokens,
|
|
661
|
+
drive.cacheReadInputTokens,
|
|
662
|
+
drive.cacheCreationInputTokens,
|
|
663
|
+
),
|
|
664
|
+
outputTokens: drive.outputTokens,
|
|
665
|
+
cacheReadTokens: drive.cacheReadInputTokens,
|
|
666
|
+
cacheWriteTokens: drive.cacheCreationInputTokens,
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
'imprint.audit.turns': drive.turns,
|
|
670
|
+
...(drive.totalCostUsd != null
|
|
671
|
+
? { 'imprint.audit.provider_reported_cost_usd': drive.totalCostUsd }
|
|
672
|
+
: {}),
|
|
673
|
+
},
|
|
674
|
+
);
|
|
481
675
|
|
|
482
676
|
// Cross-reference compile-time live verification with the audit grade.
|
|
483
677
|
// The downgrade rule's purpose is to surface "flying blind" runs —
|
|
@@ -537,15 +731,6 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
537
731
|
}
|
|
538
732
|
}
|
|
539
733
|
|
|
540
|
-
// TOTAL prompt (uncached + cache) for the cost calc; the cache split is
|
|
541
|
-
// passed to llmSpanAttributes separately. Always a number here
|
|
542
|
-
// (drive.inputTokens is non-null), so the cost-suppression happens via the
|
|
543
|
-
// `|| undefined` at the call site below.
|
|
544
|
-
const totalInputTokens = totalPromptTokens(
|
|
545
|
-
drive.inputTokens,
|
|
546
|
-
drive.cacheReadInputTokens,
|
|
547
|
-
drive.cacheCreationInputTokens,
|
|
548
|
-
);
|
|
549
734
|
setSpanAttributes(span, {
|
|
550
735
|
'imprint.audit.score': score.score,
|
|
551
736
|
'imprint.audit.correct': score.correct,
|
|
@@ -558,6 +743,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
558
743
|
'imprint.audit.params_broken': score.paramsBroken,
|
|
559
744
|
'imprint.audit.params_untestable': score.paramsUntestable,
|
|
560
745
|
'imprint.audit.tool_count': toolCount,
|
|
746
|
+
'imprint.audit.audited_tool_count': auditedToolCount,
|
|
747
|
+
'imprint.audit.detected_tool_count': detectedToolCount,
|
|
748
|
+
'imprint.audit.detected_but_unemitted_count': detectedButUnemitted.length,
|
|
749
|
+
'imprint.audit.skipped_interactive_auth_count': skippedInteractiveAuth.length,
|
|
561
750
|
'imprint.audit.verdict': score.verdict,
|
|
562
751
|
'imprint.audit.unverified_and_ungradeable_count': unverifiedAndUngradeable.length,
|
|
563
752
|
'imprint.audit.missing_tool_count': missingToolNames.length,
|
|
@@ -565,16 +754,6 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
565
754
|
'imprint.audit.timed_out': drive.timedOut,
|
|
566
755
|
'imprint.audit.turns': drive.turns,
|
|
567
756
|
...(drive.totalCostUsd != null ? { 'imprint.audit.cost_usd': drive.totalCostUsd } : {}),
|
|
568
|
-
...llmSpanAttributes({
|
|
569
|
-
provider,
|
|
570
|
-
model,
|
|
571
|
-
// `|| undefined`: when no usage was captured (e.g. spawn failure → 0
|
|
572
|
-
// tokens), suppress a bogus $0 cost instead of emitting it.
|
|
573
|
-
inputTokens: totalInputTokens || undefined,
|
|
574
|
-
outputTokens: drive.outputTokens || undefined,
|
|
575
|
-
cacheReadTokens: drive.cacheReadInputTokens || undefined,
|
|
576
|
-
cacheWriteTokens: drive.cacheCreationInputTokens || undefined,
|
|
577
|
-
}),
|
|
578
757
|
});
|
|
579
758
|
|
|
580
759
|
// Persist the full result (deterministic score + the raw model report).
|
|
@@ -583,6 +762,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
583
762
|
report: drive.report,
|
|
584
763
|
site: opts.site,
|
|
585
764
|
toolCount,
|
|
765
|
+
auditedToolCount,
|
|
766
|
+
detectedToolCount,
|
|
767
|
+
detectedButUnemittedTools: detectedButUnemitted,
|
|
768
|
+
skippedInteractiveAuth,
|
|
586
769
|
ungradeableTools: ungradeableNames,
|
|
587
770
|
/** Connected tools without an invocation record. A partial audit
|
|
588
771
|
* cannot pass merely because the auditor named an uncalled tool. */
|
|
@@ -623,6 +806,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
|
|
|
623
806
|
missingTools: missingToolNames,
|
|
624
807
|
missingParameters,
|
|
625
808
|
report: drive.report,
|
|
809
|
+
auditedToolCount,
|
|
810
|
+
skippedInteractiveAuth,
|
|
811
|
+
detectedToolCount,
|
|
812
|
+
detectedButUnemittedTools: detectedButUnemitted,
|
|
626
813
|
});
|
|
627
814
|
}
|
|
628
815
|
|
|
@@ -661,6 +848,9 @@ interface DriveAuditOptions {
|
|
|
661
848
|
timeoutMs: number;
|
|
662
849
|
systemPromptPath: string;
|
|
663
850
|
toolNames: string[];
|
|
851
|
+
/** Eligible password-only/direct-success auth tools. Interactive auth tools
|
|
852
|
+
* are filtered before the MCP session is created and never appear here. */
|
|
853
|
+
nonInteractiveAuthNames?: string[];
|
|
664
854
|
/** Per-tool params that shipped live-unverified at compile time. */
|
|
665
855
|
unverifiedParams: Array<{ tool: string; params: string[] }>;
|
|
666
856
|
/** Producer→consumer token contracts (param.sourcedFrom) so the auditor chains. */
|
|
@@ -723,17 +913,18 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
|
|
|
723
913
|
// with zero tools. The `imprint-audit-` prefix keeps the inline server unique.
|
|
724
914
|
const serverName = `imprint-audit-${opts.site}`;
|
|
725
915
|
const bunPath = process.execPath;
|
|
916
|
+
const mcpEnv = buildAuditMcpEnvironment();
|
|
726
917
|
const mcpConfig = {
|
|
727
918
|
mcpServers: {
|
|
728
919
|
[serverName]: {
|
|
729
920
|
command: bunPath,
|
|
730
|
-
args:
|
|
921
|
+
args: buildAuditMcpCommandArgs(opts.site, opts.toolNames),
|
|
731
922
|
// Pace every audit tool call: the auditor now differentially probes
|
|
732
923
|
// bot-defended idempotent reads (search/calendar) instead of bailing
|
|
733
924
|
// after one call, so a deliberate inter-call delay keeps the probing
|
|
734
925
|
// steady enough that the per-IP anti-bot defense isn't tripped. Only
|
|
735
926
|
// the audit sets this; production mcp-server runs unpaced.
|
|
736
|
-
env:
|
|
927
|
+
env: mcpEnv,
|
|
737
928
|
},
|
|
738
929
|
},
|
|
739
930
|
};
|
|
@@ -829,7 +1020,7 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
|
|
|
829
1020
|
async function driveAuditWithCodex(opts: DriveAuditOptions): Promise<DriveAuditResult> {
|
|
830
1021
|
const serverName = `imprint-audit-${opts.site}`;
|
|
831
1022
|
const bunPath = process.execPath;
|
|
832
|
-
const mcpArgs =
|
|
1023
|
+
const mcpArgs = buildAuditMcpCommandArgs(opts.site, opts.toolNames);
|
|
833
1024
|
const mcpEnv = buildAuditMcpEnvironment();
|
|
834
1025
|
|
|
835
1026
|
const unverifiedNote = buildUnverifiedParamNote(opts.unverifiedParams);
|
|
@@ -919,7 +1110,7 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
|
|
|
919
1110
|
transcript: session.transcript,
|
|
920
1111
|
inputTokens: session.inputTokens,
|
|
921
1112
|
outputTokens: session.outputTokens,
|
|
922
|
-
cacheReadInputTokens:
|
|
1113
|
+
cacheReadInputTokens: session.cacheReadInputTokens,
|
|
923
1114
|
cacheCreationInputTokens: 0,
|
|
924
1115
|
totalCostUsd: null,
|
|
925
1116
|
};
|
|
@@ -937,6 +1128,10 @@ export function buildUnverifiedParamNote(
|
|
|
937
1128
|
}
|
|
938
1129
|
|
|
939
1130
|
export function buildAuditInitialPrompt(opts: DriveAuditOptions, unverifiedNote: string): string {
|
|
1131
|
+
const nonInteractiveAuthNames = opts.nonInteractiveAuthNames ?? [];
|
|
1132
|
+
const authNote = nonInteractiveAuthNames.length
|
|
1133
|
+
? `\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.`
|
|
1134
|
+
: '\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.';
|
|
940
1135
|
return `Audit every MCP tool connected to you for the site "${opts.site}".
|
|
941
1136
|
|
|
942
1137
|
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.
|
|
@@ -945,7 +1140,7 @@ Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/bro
|
|
|
945
1140
|
|
|
946
1141
|
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.
|
|
947
1142
|
|
|
948
|
-
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)}
|
|
1143
|
+
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)}
|
|
949
1144
|
|
|
950
1145
|
When you are done, end your final message with exactly one fenced \`\`\`json block containing the full report and nothing after it.`;
|
|
951
1146
|
}
|
|
@@ -1130,6 +1325,7 @@ async function collectCodexAssistantText(
|
|
|
1130
1325
|
let turns = 0;
|
|
1131
1326
|
let inputTokens = 0;
|
|
1132
1327
|
let outputTokens = 0;
|
|
1328
|
+
let cacheReadInputTokens = 0;
|
|
1133
1329
|
const t0 = Date.now();
|
|
1134
1330
|
const elapsedStr = (): string => {
|
|
1135
1331
|
const s = Math.floor((Date.now() - t0) / 1000);
|
|
@@ -1179,6 +1375,7 @@ async function collectCodexAssistantText(
|
|
|
1179
1375
|
if (evt.type === 'turn.completed') {
|
|
1180
1376
|
inputTokens += evt.usage?.input_tokens ?? 0;
|
|
1181
1377
|
outputTokens += evt.usage?.output_tokens ?? 0;
|
|
1378
|
+
cacheReadInputTokens += evt.usage?.cached_input_tokens ?? 0;
|
|
1182
1379
|
log(`[${elapsedStr()}] turn ${turns} completed`);
|
|
1183
1380
|
continue;
|
|
1184
1381
|
}
|
|
@@ -1233,7 +1430,7 @@ async function collectCodexAssistantText(
|
|
|
1233
1430
|
turns,
|
|
1234
1431
|
inputTokens,
|
|
1235
1432
|
outputTokens,
|
|
1236
|
-
cacheReadInputTokens
|
|
1433
|
+
cacheReadInputTokens,
|
|
1237
1434
|
cacheCreationInputTokens: 0,
|
|
1238
1435
|
totalCostUsd: null,
|
|
1239
1436
|
};
|
|
@@ -1283,6 +1480,8 @@ interface CodexStreamJsonEvent {
|
|
|
1283
1480
|
usage?: {
|
|
1284
1481
|
input_tokens?: number;
|
|
1285
1482
|
output_tokens?: number;
|
|
1483
|
+
cached_input_tokens?: number;
|
|
1484
|
+
reasoning_output_tokens?: number;
|
|
1286
1485
|
};
|
|
1287
1486
|
message?: string;
|
|
1288
1487
|
error?: { message?: string };
|
|
@@ -1391,6 +1590,10 @@ function printSummary(
|
|
|
1391
1590
|
missingTools: string[];
|
|
1392
1591
|
missingParameters: MissingAuditedParameter[];
|
|
1393
1592
|
report: AuditReport;
|
|
1593
|
+
auditedToolCount: number;
|
|
1594
|
+
skippedInteractiveAuth: SkippedInteractiveAuth[];
|
|
1595
|
+
detectedToolCount: number;
|
|
1596
|
+
detectedButUnemittedTools: DetectedButUnemittedTool[];
|
|
1394
1597
|
},
|
|
1395
1598
|
): void {
|
|
1396
1599
|
const pct = score.graded === 0 ? 'n/a' : `${score.score.toFixed(1)}%`;
|
|
@@ -1404,8 +1607,18 @@ function printSummary(
|
|
|
1404
1607
|
const invGraded = score.graded - paramsTested;
|
|
1405
1608
|
const invTotal = invGraded + score.infra + score.badParams;
|
|
1406
1609
|
console.log(
|
|
1407
|
-
`[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)`,
|
|
1610
|
+
`[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)`,
|
|
1408
1611
|
);
|
|
1612
|
+
for (const skipped of extra.skippedInteractiveAuth) {
|
|
1613
|
+
console.log(
|
|
1614
|
+
`[imprint] skipped interactive auth ${skipped.name} — ${skipped.reason}; verification receipt: ${skipped.verificationStatus}`,
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
if (extra.detectedButUnemittedTools.length > 0) {
|
|
1618
|
+
console.log(
|
|
1619
|
+
`[imprint] ${extra.detectedButUnemittedTools.length}/${extra.detectedToolCount} detected tool(s) were not emitted and are excluded from scoring: ${extra.detectedButUnemittedTools.map((tool) => tool.name).join(', ')}`,
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1409
1622
|
if (paramsTested + score.paramsUntestable > 0) {
|
|
1410
1623
|
console.log(
|
|
1411
1624
|
`[imprint] parameters: ${score.paramsWorking}/${paramsTested} working — ${score.paramsNoOp} no-op, ${score.paramsBroken} broken, ${score.paramsUntestable} untestable`,
|
|
@@ -362,30 +362,81 @@ export function authWorkflowHash(workflow: Workflow): string {
|
|
|
362
362
|
return createHash('sha256').update(JSON.stringify(workflow)).digest('hex');
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
|
|
365
|
+
export interface AuthVerificationReceiptStatus {
|
|
366
|
+
status: 'verified' | 'stale' | 'failed' | 'missing';
|
|
367
|
+
reason: string;
|
|
368
|
+
action?: string;
|
|
369
|
+
backend?: string;
|
|
370
|
+
timestamp?: number;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Read the latest auth verification receipt without initiating authentication.
|
|
374
|
+
* The workflow hash is authoritative: a successful receipt for older generated
|
|
375
|
+
* code is diagnostic history, not current verification evidence. */
|
|
376
|
+
export function readAuthVerificationReceiptStatus(
|
|
377
|
+
toolDir: string,
|
|
378
|
+
workflow: Workflow,
|
|
379
|
+
): AuthVerificationReceiptStatus {
|
|
366
380
|
const path = pathJoin(toolDir, AUTH_VERIFICATION_ATTEMPT_SENTINEL);
|
|
367
381
|
if (!existsSync(path)) {
|
|
368
|
-
return
|
|
382
|
+
return {
|
|
383
|
+
status: 'missing',
|
|
384
|
+
reason: 'no live auth verification attempt was recorded',
|
|
385
|
+
};
|
|
369
386
|
}
|
|
370
387
|
|
|
371
|
-
let attempt: {
|
|
388
|
+
let attempt: {
|
|
389
|
+
action?: unknown;
|
|
390
|
+
ok?: unknown;
|
|
391
|
+
workflowHash?: unknown;
|
|
392
|
+
backend?: unknown;
|
|
393
|
+
timestamp?: unknown;
|
|
394
|
+
};
|
|
372
395
|
try {
|
|
373
396
|
attempt = JSON.parse(readFileSync(path, 'utf8')) as typeof attempt;
|
|
374
397
|
} catch (err) {
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
|
|
398
|
+
return {
|
|
399
|
+
status: 'failed',
|
|
400
|
+
reason: `live auth verification record is invalid: ${err instanceof Error ? err.message : String(err)}`,
|
|
401
|
+
};
|
|
378
402
|
}
|
|
403
|
+
const details = {
|
|
404
|
+
...(typeof attempt.action === 'string' ? { action: attempt.action } : {}),
|
|
405
|
+
...(typeof attempt.backend === 'string' ? { backend: attempt.backend } : {}),
|
|
406
|
+
...(typeof attempt.timestamp === 'number' ? { timestamp: attempt.timestamp } : {}),
|
|
407
|
+
};
|
|
379
408
|
if (attempt.ok !== true || typeof attempt.action !== 'string') {
|
|
380
|
-
return
|
|
409
|
+
return {
|
|
410
|
+
status: 'failed',
|
|
411
|
+
reason: 'the most recent live auth verification did not succeed',
|
|
412
|
+
...details,
|
|
413
|
+
};
|
|
381
414
|
}
|
|
382
415
|
if (attempt.workflowHash !== authWorkflowHash(workflow)) {
|
|
383
|
-
return
|
|
416
|
+
return {
|
|
417
|
+
status: 'stale',
|
|
418
|
+
reason: 'workflow.json changed after the most recent successful live auth verification',
|
|
419
|
+
...details,
|
|
420
|
+
};
|
|
384
421
|
}
|
|
385
422
|
const action = workflow.authConfig?.actions[attempt.action];
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
423
|
+
if (action?.outcome.type !== 'success') {
|
|
424
|
+
return {
|
|
425
|
+
status: 'failed',
|
|
426
|
+
reason: `the most recent successful live action ${JSON.stringify(attempt.action)} does not declare a success outcome`,
|
|
427
|
+
...details,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
status: 'verified',
|
|
432
|
+
reason: `live auth verification succeeded with action ${JSON.stringify(attempt.action)}`,
|
|
433
|
+
...details,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function liveAttemptFailures(toolDir: string, workflow: Workflow): string[] {
|
|
438
|
+
const receipt = readAuthVerificationReceiptStatus(toolDir, workflow);
|
|
439
|
+
if (receipt.status === 'verified') return [];
|
|
440
|
+
const suffix = receipt.status === 'missing' ? '; call run_verification before done' : '';
|
|
441
|
+
return [`${receipt.reason}${suffix}`];
|
|
391
442
|
}
|
|
@@ -43,7 +43,7 @@ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compi
|
|
|
43
43
|
import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
|
|
44
44
|
import {
|
|
45
45
|
endTraceSpan,
|
|
46
|
-
|
|
46
|
+
recordLlmUsageSpan,
|
|
47
47
|
setSpanAttributes,
|
|
48
48
|
startTraceSpan,
|
|
49
49
|
totalPromptTokens,
|
|
@@ -88,6 +88,28 @@ const USAGE_POLICY_REFUSAL =
|
|
|
88
88
|
/** Total attempts (1 initial + retries) when a usage-policy refusal is hit. */
|
|
89
89
|
const MAX_USAGE_POLICY_ATTEMPTS = 3;
|
|
90
90
|
|
|
91
|
+
interface CompileUsageTotals {
|
|
92
|
+
turns: number;
|
|
93
|
+
inputTokens: number;
|
|
94
|
+
outputTokens: number;
|
|
95
|
+
cacheReadInputTokens: number;
|
|
96
|
+
cacheCreationInputTokens: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Add one paid CLI attempt to the aggregate usage reported for the compile. */
|
|
100
|
+
export function addCompileUsageTotals(
|
|
101
|
+
totals: CompileUsageTotals,
|
|
102
|
+
attempt: CompileUsageTotals,
|
|
103
|
+
): CompileUsageTotals {
|
|
104
|
+
return {
|
|
105
|
+
turns: totals.turns + attempt.turns,
|
|
106
|
+
inputTokens: totals.inputTokens + attempt.inputTokens,
|
|
107
|
+
outputTokens: totals.outputTokens + attempt.outputTokens,
|
|
108
|
+
cacheReadInputTokens: totals.cacheReadInputTokens + attempt.cacheReadInputTokens,
|
|
109
|
+
cacheCreationInputTokens: totals.cacheCreationInputTokens + attempt.cacheCreationInputTokens,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/** Exponential backoff with jitter between refusal retries. Spacing matters:
|
|
92
114
|
* bursts of near-identical requests raise the safety-filter trip rate. */
|
|
93
115
|
function usagePolicyBackoffMs(attempt: number): number {
|
|
@@ -212,6 +234,7 @@ export async function compileViaClaudeCli(
|
|
|
212
234
|
},
|
|
213
235
|
async (span) => {
|
|
214
236
|
const result = await compileViaClaudeCliImpl(opts);
|
|
237
|
+
const model = opts.model ?? preferredAgentModel('claude-cli');
|
|
215
238
|
setSpanAttributes(span, {
|
|
216
239
|
'imprint.compile.outcome': result.outcome,
|
|
217
240
|
'imprint.compile.turns': result.turns,
|
|
@@ -220,9 +243,12 @@ export async function compileViaClaudeCli(
|
|
|
220
243
|
'imprint.compile.output_tokens': result.outputTokens,
|
|
221
244
|
'imprint.compile.cache_read_input_tokens': result.cacheReadInputTokens,
|
|
222
245
|
'imprint.compile.cache_creation_input_tokens': result.cacheCreationInputTokens,
|
|
223
|
-
|
|
246
|
+
});
|
|
247
|
+
recordLlmUsageSpan(
|
|
248
|
+
'compile.claude_cli_usage',
|
|
249
|
+
{
|
|
224
250
|
provider: 'claude-cli',
|
|
225
|
-
model
|
|
251
|
+
model,
|
|
226
252
|
// TOTAL prompt (uncached + cache); the cache split is passed separately
|
|
227
253
|
// for cost. `result.inputTokens` alone is the uncached delta (often a
|
|
228
254
|
// few hundred), which would mislabel `llm.token_count.prompt`.
|
|
@@ -234,8 +260,9 @@ export async function compileViaClaudeCli(
|
|
|
234
260
|
outputTokens: result.outputTokens,
|
|
235
261
|
cacheReadTokens: result.cacheReadInputTokens,
|
|
236
262
|
cacheWriteTokens: result.cacheCreationInputTokens,
|
|
237
|
-
}
|
|
238
|
-
|
|
263
|
+
},
|
|
264
|
+
{ 'imprint.compile.turns': result.turns },
|
|
265
|
+
);
|
|
239
266
|
return result;
|
|
240
267
|
},
|
|
241
268
|
);
|
|
@@ -251,11 +278,20 @@ async function compileViaClaudeCliImpl(
|
|
|
251
278
|
opts: CompileViaClaudeCliOptions,
|
|
252
279
|
): Promise<CompileAgentResult> {
|
|
253
280
|
let lastResult: CompileAgentResult | undefined;
|
|
281
|
+
let usageTotals: CompileUsageTotals = {
|
|
282
|
+
turns: 0,
|
|
283
|
+
inputTokens: 0,
|
|
284
|
+
outputTokens: 0,
|
|
285
|
+
cacheReadInputTokens: 0,
|
|
286
|
+
cacheCreationInputTokens: 0,
|
|
287
|
+
};
|
|
254
288
|
for (let attempt = 1; attempt <= MAX_USAGE_POLICY_ATTEMPTS; attempt++) {
|
|
255
289
|
const result = await runClaudeCliAttempt(opts);
|
|
290
|
+
usageTotals = addCompileUsageTotals(usageTotals, result);
|
|
291
|
+
const resultWithAggregateUsage = { ...result, ...usageTotals };
|
|
256
292
|
const isRefusal = !result.success && USAGE_POLICY_REFUSAL.test(result.message ?? '');
|
|
257
|
-
if (!isRefusal) return
|
|
258
|
-
lastResult =
|
|
293
|
+
if (!isRefusal) return resultWithAggregateUsage;
|
|
294
|
+
lastResult = resultWithAggregateUsage;
|
|
259
295
|
if (attempt < MAX_USAGE_POLICY_ATTEMPTS) {
|
|
260
296
|
const backoffMs = usagePolicyBackoffMs(attempt);
|
|
261
297
|
log(
|