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.
- package/README.md +2 -0
- package/package.json +3 -2
- package/prompts/audit-agent.md +10 -7
- package/prompts/build-planning.md +2 -2
- package/prompts/compile-agent.md +49 -24
- package/prompts/live-verifier-agent.md +114 -0
- package/prompts/playbook-compilation.md +8 -1
- package/src/cli.ts +121 -4
- package/src/imprint/agent.ts +7 -2
- package/src/imprint/audit.ts +430 -39
- package/src/imprint/auth-compile-tools.ts +64 -13
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +48 -34
- package/src/imprint/cdp-browser-fetch.ts +135 -42
- package/src/imprint/claude-cli-compile.ts +38 -16
- package/src/imprint/codex-cli-compile.ts +120 -21
- package/src/imprint/compile-agent-types.ts +25 -2
- package/src/imprint/compile-agent.ts +238 -36
- package/src/imprint/compile-tools.ts +331 -58
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1826 -0
- package/src/imprint/llm.ts +20 -0
- package/src/imprint/mcp-compile-server.ts +488 -84
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +28 -3
- package/src/imprint/probe-backends.ts +147 -31
- package/src/imprint/runtime.ts +61 -11
- package/src/imprint/teach-state.ts +70 -3
- package/src/imprint/teach.ts +171 -88
- package/src/imprint/types.ts +39 -2
package/src/imprint/audit.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
|
264
|
-
const toolCount =
|
|
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
|
|
282
|
-
|
|
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 =
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 =
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
);
|