imprint-mcp 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/prompts/audit-agent.md +3 -3
  4. package/prompts/auth-compile-agent.md +46 -4
  5. package/prompts/compile-agent.md +36 -12
  6. package/prompts/live-verifier-agent.md +15 -5
  7. package/prompts/request-triage.md +51 -9
  8. package/prompts/tool-candidate-detection.md +7 -1
  9. package/prompts/tool-planning.md +1 -1
  10. package/src/cli.ts +71 -13
  11. package/src/imprint/audit.ts +23 -5
  12. package/src/imprint/auth-compile-agent.ts +11 -7
  13. package/src/imprint/auth-compile-tools.ts +127 -18
  14. package/src/imprint/auth-verifier.ts +2 -1
  15. package/src/imprint/backend-ladder.ts +20 -0
  16. package/src/imprint/build-plan.ts +21 -0
  17. package/src/imprint/claude-cli-compile.ts +7 -1
  18. package/src/imprint/codex-cli-compile.ts +6 -0
  19. package/src/imprint/compile-agent.ts +50 -9
  20. package/src/imprint/compile-tools.ts +308 -25
  21. package/src/imprint/compile-verification.ts +11 -2
  22. package/src/imprint/compile.ts +298 -69
  23. package/src/imprint/effects.ts +9 -0
  24. package/src/imprint/live-verifier.ts +455 -34
  25. package/src/imprint/login.ts +30 -12
  26. package/src/imprint/mcp-compile-server.ts +56 -5
  27. package/src/imprint/mcp-live-verifier-server.ts +43 -10
  28. package/src/imprint/probe-backends.ts +6 -0
  29. package/src/imprint/recording-request.ts +7 -2
  30. package/src/imprint/replay-capture.ts +48 -29
  31. package/src/imprint/request-capture.ts +14 -0
  32. package/src/imprint/runtime.ts +102 -22
  33. package/src/imprint/teach-state.ts +162 -21
  34. package/src/imprint/teach.ts +629 -226
  35. package/src/imprint/tool-candidates.ts +138 -0
  36. package/src/imprint/tool-plan.ts +2 -0
  37. package/src/imprint/triage-selection.ts +44 -0
  38. package/src/imprint/types.ts +39 -3
@@ -35,6 +35,7 @@ import {
35
35
  readLiveIntegrationEvidence,
36
36
  } from './compile-verification.ts';
37
37
  import { splitSetCookieHeader } from './cookie-jar.ts';
38
+ import { isIrreversibleRequest, workflowHasIrreversibleEffect } from './effects.ts';
38
39
  import { isSameRegistrableDomain, registrableDomain } from './etld.ts';
39
40
  import {
40
41
  endpointsForSeqs,
@@ -44,6 +45,7 @@ import {
44
45
  } from './param-grounding.ts';
45
46
  import { detectPageMintedHeaders } from './redact.ts';
46
47
  import { compactRequestContexts, requestContextDigest } from './request-context.ts';
48
+ import { ensureImprintRuntimeLink } from './runtime-link.ts';
47
49
  import { isSensitiveHeader } from './sensitive-keys.ts';
48
50
  import { type ClassifiedValue, looksLikeToken } from './session-diff.ts';
49
51
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
@@ -53,11 +55,40 @@ import {
53
55
  type RequestCapture,
54
56
  type Session,
55
57
  SessionSchema,
58
+ type Workflow,
56
59
  WorkflowSchema,
57
60
  } from './types.ts';
58
61
 
59
62
  const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
60
63
  const MAX_SHARED_MODULE_SOURCE_CHARS = 50_000;
64
+ const RUNTIME_BODY_PLACEHOLDER =
65
+ /\$\{(?:(?:param|credential|response|generated|env)\.|(?:state|cookie)(?:\.|\["))/;
66
+
67
+ export function requestsNeedingBodyEncodingDecision(workflow: Workflow): number[] {
68
+ return workflow.requests.flatMap((request, index) =>
69
+ request.body && RUNTIME_BODY_PLACEHOLDER.test(request.body) ? [index] : [],
70
+ );
71
+ }
72
+
73
+ /** Compile-time contract only; legacy workflows retain runtime inference. */
74
+ export function bodyEncodingContractFailures(workflow: Workflow): string[] {
75
+ return requestsNeedingBodyEncodingDecision(workflow)
76
+ .filter((index) => workflow.requests[index]?.bodyPlaceholderEncoding === undefined)
77
+ .map(
78
+ (index) =>
79
+ `request ${index} has runtime placeholders in its body but no bodyPlaceholderEncoding. Inspect the recorded wire format, choose raw, json-string, or form-urlencoded, then prove the choice with an agent-authored offline request.test.ts.`,
80
+ );
81
+ }
82
+
83
+ export function requestEncodingTestContractFailures(
84
+ workflow: Workflow,
85
+ source: string | undefined,
86
+ ): string[] {
87
+ if (requestsNeedingBodyEncodingDecision(workflow).length === 0) return [];
88
+ return source === undefined
89
+ ? ['request.test.ts is required for a placeholder-bearing request body']
90
+ : [];
91
+ }
61
92
 
62
93
  // Env var read by the agent-written parser.test.ts to locate the redacted
63
94
  // session. The test loads it, finds the load-bearing request seq, and feeds
@@ -87,6 +118,17 @@ export function buildCompileTools(
87
118
  const credEnv = context.teachCredentials
88
119
  ? { IMPRINT_TEACH_CREDENTIALS: JSON.stringify(context.teachCredentials) }
89
120
  : undefined;
121
+ const candidateSeqs = context.candidate
122
+ ? new Set([
123
+ ...context.candidate.requestSeqs,
124
+ ...context.candidate.dependencySeqs,
125
+ ...(context.sharedContext?.loginRequestSeqs ?? []),
126
+ ])
127
+ : undefined;
128
+ const hasIrreversibleCandidateRequest = session.requests.some(
129
+ (request) =>
130
+ isIrreversibleRequest(request) && (!candidateSeqs || candidateSeqs.has(request.seq)),
131
+ );
90
132
  const tools = [
91
133
  buildReadSessionSummaryTool(session, context, toolDir),
92
134
  buildReadRequestTool(session),
@@ -97,9 +139,15 @@ export function buildCompileTools(
97
139
  buildSearchResponseBodyTool(session),
98
140
  buildWriteFileTool(toolDir),
99
141
  buildReadFileTool(toolDir),
100
- buildRunBashTool(toolDir, credEnv),
101
- buildRunTestsTool(toolDir, sessionPath, credEnv),
102
142
  ];
143
+ tools.push(
144
+ buildRunTestsTool(toolDir, sessionPath, credEnv, {
145
+ networkDisabled: hasIrreversibleCandidateRequest,
146
+ }),
147
+ );
148
+ if (!hasIrreversibleCandidateRequest) {
149
+ tools.push(buildRunBashTool(toolDir, credEnv));
150
+ }
103
151
  if (context.buildPlanPath && context.candidate?.toolName) {
104
152
  tools.push(
105
153
  buildReadBuildPlanTool(
@@ -346,6 +394,7 @@ export function buildReadSessionSummaryTool(
346
394
  mimeType: r.response?.mimeType,
347
395
  bodySize: r.response?.body?.length,
348
396
  responseBodyDigest: requestContextDigest(r.response?.body),
397
+ ...(isIrreversibleRequest(r) ? { irreversible: true } : {}),
349
398
  ...(preserveSeqs.has(r.seq) ? { inlineData: buildInlineData(r) } : {}),
350
399
  })),
351
400
  compileSummaryRequestGroupKey,
@@ -370,6 +419,7 @@ export function buildReadSessionSummaryTool(
370
419
  ? context.candidate.representativeSeqs
371
420
  : context.candidate.requestSeqs,
372
421
  dependencySeqs: context.candidate.dependencySeqs,
422
+ dependsOnTools: context.candidate.dependsOnTools,
373
423
  eventSeqs: context.candidate.eventSeqs,
374
424
  likelyParams: context.candidate.likelyParams,
375
425
  }
@@ -406,6 +456,7 @@ export function buildReadSessionSummaryTool(
406
456
  const REVISION_ARTIFACT_NAMES = [
407
457
  'workflow.json',
408
458
  'parser.ts',
459
+ 'request.test.ts',
409
460
  'request-transform.ts',
410
461
  'integration.test.ts',
411
462
  'playbook.yaml',
@@ -905,6 +956,7 @@ interface CompileSummaryRequestContext {
905
956
  mimeType?: string;
906
957
  bodySize?: number;
907
958
  responseBodyDigest?: string;
959
+ irreversible?: boolean;
908
960
  repeatCount?: number;
909
961
  repeatedSeqs?: number[];
910
962
  lastTimestamp?: number;
@@ -918,6 +970,7 @@ function compileSummaryRequestGroupKey(request: CompileSummaryRequestContext): u
918
970
  request.mimeType,
919
971
  request.bodySize,
920
972
  request.responseBodyDigest,
973
+ request.irreversible,
921
974
  ];
922
975
  }
923
976
 
@@ -1306,6 +1359,7 @@ export function buildWriteFileTool(toolDir: string, extraAllowed: string[] = [])
1306
1359
  'workflow.json',
1307
1360
  'parser.ts',
1308
1361
  'parser.test.ts',
1362
+ 'request.test.ts',
1309
1363
  'request-transform.ts',
1310
1364
  'integration.test.ts',
1311
1365
  ...extraAllowed,
@@ -1412,7 +1466,7 @@ export function buildReadFileTool(toolDir: string): AgentTool {
1412
1466
 
1413
1467
  // ─── Tool: run_bash ──────────────────────────────────────────────────────────
1414
1468
 
1415
- export function buildRunBashTool(toolDir: string, credEnv?: Record<string, string>): AgentTool {
1469
+ function buildRunBashTool(toolDir: string, credEnv?: Record<string, string>): AgentTool {
1416
1470
  return {
1417
1471
  name: 'run_bash',
1418
1472
  description: 'Run a shell command in the generated tool directory with a timeout.',
@@ -1435,7 +1489,6 @@ export function buildRunBashTool(toolDir: string, credEnv?: Record<string, strin
1435
1489
  }
1436
1490
 
1437
1491
  const cappedTimeout = Math.min(timeoutSec, 300) * 1000;
1438
-
1439
1492
  return await runCommand(command, toolDir, cappedTimeout, credEnv);
1440
1493
  },
1441
1494
  };
@@ -1554,6 +1607,13 @@ export async function typecheckArtifacts(
1554
1607
  extends: extendsPath,
1555
1608
  include: includes,
1556
1609
  exclude: ['*.test.ts'],
1610
+ compilerOptions: {
1611
+ // Generated tools normally live under ~/.imprint, outside the repo's
1612
+ // node_modules ancestry. Extending the repo tsconfig does not change
1613
+ // where TypeScript searches for named `types`, so point that lookup
1614
+ // at the dependencies of the runtime being compiled.
1615
+ typeRoots: [pathJoin(REPO_ROOT, 'node_modules', '@types')],
1616
+ },
1557
1617
  },
1558
1618
  null,
1559
1619
  2,
@@ -1589,30 +1649,60 @@ function normalizeTsconfigPath(value: string): string {
1589
1649
 
1590
1650
  // ─── Tool: run_tests ─────────────────────────────────────────────────────────
1591
1651
 
1592
- function buildRunTestsTool(
1652
+ export function buildRunTestsTool(
1593
1653
  toolDir: string,
1594
1654
  sessionPath: string,
1595
1655
  credEnv?: Record<string, string>,
1656
+ opts: { networkDisabled?: boolean } = {},
1596
1657
  ): AgentTool {
1597
1658
  return {
1598
1659
  name: 'run_tests',
1599
1660
  description:
1600
- 'Run bun test parser.test.ts plus strict TypeScript checks for generated parser/request-transform artifacts, then parse pass/fail counts.',
1661
+ 'Run the agent-written parser.test.ts and/or request.test.ts plus strict TypeScript checks for generated parser/request-transform artifacts, then report pass/fail counts.',
1601
1662
  input_schema: {
1602
1663
  type: 'object',
1603
1664
  properties: {},
1604
1665
  required: [],
1605
1666
  },
1606
1667
  handler: async () => {
1607
- const testPath = pathJoin(toolDir, 'parser.test.ts');
1608
- if (!existsSync(testPath)) {
1668
+ const testNames = ['parser.test.ts', 'request.test.ts'].filter((name) =>
1669
+ existsSync(pathJoin(toolDir, name)),
1670
+ );
1671
+ if (testNames.length === 0) {
1609
1672
  return {
1610
- result: 'parser.test.ts does not exist — write it first',
1673
+ result: 'no generated tests exist — write parser.test.ts and/or request.test.ts first',
1611
1674
  isError: true,
1612
1675
  };
1613
1676
  }
1614
1677
 
1615
- const cmdResult = await runCommand('bun test parser.test.ts', toolDir, 120000, {
1678
+ // Agent tests execute from ~/.imprint/<site>/<tool>. Ensure their
1679
+ // `imprint/*` imports resolve to the runtime currently doing the compile,
1680
+ // rather than a stale global install or vanished worktree.
1681
+ const imprintHome = pathJoin(toolDir, '..', '..');
1682
+ ensureImprintRuntimeLink(dirname(imprintHome) !== imprintHome ? imprintHome : toolDir);
1683
+
1684
+ let workflowRequiresIsolation = false;
1685
+ try {
1686
+ workflowRequiresIsolation = workflowHasIrreversibleEffect(
1687
+ WorkflowSchema.parse(
1688
+ JSON.parse(readFileSync(pathJoin(toolDir, 'workflow.json'), 'utf8')),
1689
+ ),
1690
+ );
1691
+ } catch {
1692
+ // Workflow/schema failures are reported by the compile verifier.
1693
+ }
1694
+ const testCommand = networkIsolatedCommand(
1695
+ `bun test ${testNames.join(' ')}`,
1696
+ opts.networkDisabled === true || workflowRequiresIsolation,
1697
+ );
1698
+ if (!testCommand) {
1699
+ return {
1700
+ result:
1701
+ 'offline tests require a supported network-isolation runner for an irreversible workflow',
1702
+ isError: true,
1703
+ };
1704
+ }
1705
+ const cmdResult = await runCommand(testCommand, toolDir, 120000, {
1616
1706
  [SESSION_PATH_ENV]: sessionPath,
1617
1707
  ...credEnv,
1618
1708
  });
@@ -1624,13 +1714,20 @@ function buildRunTestsTool(
1624
1714
  timedOut: boolean;
1625
1715
  };
1626
1716
 
1627
- const passMatch = output.stdout.match(/(\d+)\s+pass/);
1628
- const failMatch = output.stdout.match(/(\d+)\s+fail/);
1717
+ const testOutput = `${output.stdout}\n${output.stderr}`;
1718
+ const passMatch = testOutput.match(/(\d+)\s+pass/);
1719
+ const failMatch = testOutput.match(/(\d+)\s+fail/);
1629
1720
 
1630
1721
  const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
1631
1722
  const failed = failMatch?.[1] ? Number.parseInt(failMatch[1], 10) : 0;
1632
1723
  const total = passed + failed;
1633
- const typecheck = await typecheckArtifacts(toolDir, ['parser.ts', 'request-transform.ts']);
1724
+ const typecheckIncludes = ['parser.ts', 'request-transform.ts'].filter((name) =>
1725
+ existsSync(pathJoin(toolDir, name)),
1726
+ );
1727
+ const typecheck =
1728
+ typecheckIncludes.length > 0
1729
+ ? await typecheckArtifacts(toolDir, typecheckIncludes)
1730
+ : { stdout: '', stderr: '', exitCode: 0, timedOut: false };
1634
1731
  const typecheckFailed = typecheck.exitCode !== 0 || typecheck.timedOut;
1635
1732
 
1636
1733
  return {
@@ -1655,6 +1752,20 @@ function buildRunTestsTool(
1655
1752
  };
1656
1753
  }
1657
1754
 
1755
+ function networkIsolatedCommand(command: string, disabled: boolean): string | null {
1756
+ if (!disabled) return command;
1757
+ if (process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec')) {
1758
+ return `/usr/bin/sandbox-exec -p '(version 1) (allow default) (deny network*)' ${command}`;
1759
+ }
1760
+ if (process.platform === 'linux' && existsSync('/usr/bin/bwrap')) {
1761
+ return `/usr/bin/bwrap --ro-bind / / --dev /dev --proc /proc --unshare-net -- ${command}`;
1762
+ }
1763
+ if (process.platform === 'linux' && existsSync('/usr/bin/unshare')) {
1764
+ return `/usr/bin/unshare --user --map-root-user --net -- ${command}`;
1765
+ }
1766
+ return null;
1767
+ }
1768
+
1658
1769
  // ─── Test-quality helpers (shared with prereq-builder verification) ─────────
1659
1770
 
1660
1771
  /** Tautological assertions that prove nothing — rejected by every verifier so
@@ -1833,7 +1944,13 @@ export interface ParamVerification {
1833
1944
  * - `waived-chain`: the param is a producer-sourced token but the producer
1834
1945
  * tool could not be run at compile time (anti-bot / not compiled), so the
1835
1946
  * chain could not be verified. */
1836
- reason?: 'waived-bot' | 'waived-infra' | 'annotated' | 'waived-chain' | 'semantic-gap';
1947
+ reason?:
1948
+ | 'waived-bot'
1949
+ | 'waived-infra'
1950
+ | 'waived-safety'
1951
+ | 'annotated'
1952
+ | 'waived-chain'
1953
+ | 'semantic-gap';
1837
1954
  /** For a producer-sourced token param, the sibling tool + output field its
1838
1955
  * value comes from. Stamped into workflow.json (`param.sourcedFrom`) so the
1839
1956
  * MCP description tells the orchestrating LLM where to mint it and the audit
@@ -1865,6 +1982,7 @@ export async function runBunTestWithResults(
1865
1982
  opts: {
1866
1983
  bail?: boolean;
1867
1984
  onOutput?: (stream: 'stdout' | 'stderr', chunk: string) => void;
1985
+ networkDisabled?: boolean;
1868
1986
  } = {},
1869
1987
  ): Promise<BunTestRun> {
1870
1988
  const junitPath = pathJoin(toolDir, `.imprint-junit-${basename(testPath)}.xml`);
@@ -1873,13 +1991,21 @@ export async function runBunTestWithResults(
1873
1991
  } catch {
1874
1992
  // best-effort
1875
1993
  }
1876
- const result = await runCommand(
1994
+ const command = networkIsolatedCommand(
1877
1995
  `bun test ${testPath} --reporter=junit --reporter-outfile=${junitPath}${opts.bail ? ' --bail=1' : ''}`,
1878
- toolDir,
1879
- timeoutMs,
1880
- env,
1881
- opts.onOutput,
1996
+ opts.networkDisabled === true,
1882
1997
  );
1998
+ if (!command) {
1999
+ return {
2000
+ stdout: '',
2001
+ stderr: 'network-isolated test runner unavailable',
2002
+ exitCode: 1,
2003
+ timedOut: false,
2004
+ passed: new Set(),
2005
+ failed: new Set(),
2006
+ };
2007
+ }
2008
+ const result = await runCommand(command, toolDir, timeoutMs, env, opts.onOutput);
1883
2009
  const output = JSON.parse(result.result) as {
1884
2010
  stdout: string;
1885
2011
  stderr: string;
@@ -2609,7 +2735,11 @@ export function applyParamVerification(
2609
2735
  export function applyLiveVerification(
2610
2736
  toolDir: string,
2611
2737
  liveVerification:
2612
- | { kind: 'waived-bot' | 'waived-infra'; firstError: string; exhaustedBackends: string[] }
2738
+ | {
2739
+ kind: 'waived-bot' | 'waived-infra' | 'waived-safety';
2740
+ firstError: string;
2741
+ exhaustedBackends: string[];
2742
+ }
2613
2743
  | undefined,
2614
2744
  ): void {
2615
2745
  const workflowPath = pathJoin(toolDir, 'workflow.json');
@@ -2634,6 +2764,29 @@ export function applyLiveVerification(
2634
2764
  }
2635
2765
  }
2636
2766
 
2767
+ /** Apply the common compile outcome for workflows that must never be exercised
2768
+ * live. Provider frontends own their transport-specific completion messages,
2769
+ * while this helper owns the shared workflow metadata. */
2770
+ export function applyIrreversibleVerificationWaiver(toolDir: string, workflow: Workflow): string[] {
2771
+ applyLiveVerification(toolDir, {
2772
+ kind: 'waived-safety',
2773
+ firstError: 'live verification is disabled for irreversible workflows',
2774
+ exhaustedBackends: [],
2775
+ });
2776
+ return [
2777
+ 'live verification skipped: workflow declares an irreversible request',
2778
+ ...applyParamVerification(
2779
+ toolDir,
2780
+ workflow.parameters.map((parameter) => ({
2781
+ name: parameter.name,
2782
+ verified: false,
2783
+ reason: 'waived-safety' as const,
2784
+ ...(parameter.sourcedFrom ? { sourcedFrom: parameter.sourcedFrom } : {}),
2785
+ })),
2786
+ ),
2787
+ ];
2788
+ }
2789
+
2637
2790
  /** Strip `${...}` placeholders and query string from a workflow URL so it can
2638
2791
  * be compared against a recorded request URL by (origin + path). Returns null
2639
2792
  * when the URL is unparseable even after stripping. */
@@ -2670,6 +2823,55 @@ function findRecordedMatches(
2670
2823
  });
2671
2824
  }
2672
2825
 
2826
+ export function irreversibleProvenanceFailures(
2827
+ session: Session,
2828
+ workflow: ReturnType<typeof WorkflowSchema.parse>,
2829
+ scope: { candidateRequestSeqs?: number[]; dependencyRequestSeqs?: number[] } = {},
2830
+ ): string[] {
2831
+ const scopedSeqs = new Set([
2832
+ ...(scope.candidateRequestSeqs ?? []),
2833
+ ...(scope.dependencyRequestSeqs ?? []),
2834
+ ]);
2835
+ const hasScope = scopedSeqs.size > 0;
2836
+ const failures: string[] = [];
2837
+
2838
+ for (const recorded of session.requests.filter(isIrreversibleRequest)) {
2839
+ if (hasScope && !scopedSeqs.has(recorded.seq)) continue;
2840
+ const generated = workflow.requests
2841
+ .map((request, index) => ({ request, index }))
2842
+ .filter(({ request }) => request.recordingRequestSeq === recorded.seq);
2843
+ if (generated.length === 0) {
2844
+ failures.push(
2845
+ `triage classified recorded request seq ${recorded.seq} as irreversible, but workflow.json has no request with recordingRequestSeq: ${recorded.seq}`,
2846
+ );
2847
+ }
2848
+ }
2849
+
2850
+ for (const [index, request] of workflow.requests.entries()) {
2851
+ const recorded =
2852
+ request.recordingRequestSeq === undefined
2853
+ ? undefined
2854
+ : session.requests.find((candidate) => candidate.seq === request.recordingRequestSeq);
2855
+ if (recorded && isIrreversibleRequest(recorded) && !isIrreversibleRequest(request)) {
2856
+ failures.push(
2857
+ `workflow request index ${index} grounded by recordingRequestSeq ${recorded.seq} must declare effect: "irreversible"`,
2858
+ );
2859
+ }
2860
+ if (!isIrreversibleRequest(request)) continue;
2861
+ if (request.recordingRequestSeq === undefined) {
2862
+ failures.push(
2863
+ `irreversible workflow request index ${index} must include recordingRequestSeq provenance`,
2864
+ );
2865
+ } else if (!session.requests.some((recorded) => recorded.seq === request.recordingRequestSeq)) {
2866
+ failures.push(
2867
+ `irreversible workflow request index ${index} references unknown recordingRequestSeq ${request.recordingRequestSeq}`,
2868
+ );
2869
+ }
2870
+ }
2871
+
2872
+ return failures;
2873
+ }
2874
+
2673
2875
  /** Case-insensitive header lookup against a `Record<string, string>` (which
2674
2876
  * records preserve as they were captured — Chrome's DevTools protocol does not
2675
2877
  * normalize). */
@@ -3695,10 +3897,13 @@ export async function externalVerification(
3695
3897
  // firing it is pure waste that also burns the per-IP anti-bot rate budget.
3696
3898
  // Skip the live test in that case and make the agent fix the capture first.
3697
3899
  let referencedStateBroken = false;
3900
+ let irreversibleProvenanceMissing = false;
3901
+ let hasIrreversibleWorkflow = false;
3698
3902
 
3699
3903
  const workflowPath = pathJoin(toolDir, 'workflow.json');
3700
3904
  const parserPath = pathJoin(toolDir, 'parser.ts');
3701
3905
  const parserTestPath = pathJoin(toolDir, 'parser.test.ts');
3906
+ const requestTestPath = pathJoin(toolDir, 'request.test.ts');
3702
3907
  const requestTransformPath = pathJoin(toolDir, 'request-transform.ts');
3703
3908
 
3704
3909
  // Contracted-input injection + emit-time secret guard + the contracted-input
@@ -3763,6 +3968,8 @@ export async function externalVerification(
3763
3968
  try {
3764
3969
  const raw = JSON.parse(readFileSync(workflowPath, 'utf8'));
3765
3970
  const workflow = WorkflowSchema.parse(raw);
3971
+ failures.push(...bodyEncodingContractFailures(workflow));
3972
+ hasIrreversibleWorkflow = workflowHasIrreversibleEffect(workflow);
3766
3973
  if (opts.expectedToolName && workflow.toolName !== opts.expectedToolName) {
3767
3974
  failures.push(
3768
3975
  `workflow.toolName "${workflow.toolName}" does not match selected candidate "${opts.expectedToolName}"`,
@@ -3991,8 +4198,34 @@ export async function externalVerification(
3991
4198
  failures.push(...assertSharedModuleImports(toolDir, workflowPath, opts.assignedSharedModules));
3992
4199
  }
3993
4200
 
4201
+ // Irreversible-effect provenance: semantic classification belongs to triage
4202
+ // and compile agents. This deterministic gate only proves that their declared
4203
+ // effect stays attached to the exact recording request sequence.
4204
+ if (existsSync(workflowPath)) {
4205
+ try {
4206
+ const wf = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
4207
+ const irreversibleFailures = irreversibleProvenanceFailures(session, wf, {
4208
+ candidateRequestSeqs: opts.candidateRequestSeqs,
4209
+ dependencyRequestSeqs: opts.dependencyRequestSeqs,
4210
+ });
4211
+ if (irreversibleFailures.length > 0) {
4212
+ irreversibleProvenanceMissing = true;
4213
+ failures.push(...irreversibleFailures);
4214
+ }
4215
+ } catch {
4216
+ /* workflow parse already checked above */
4217
+ }
4218
+ }
4219
+
4220
+ const safetyBlocked = hasIrreversibleWorkflow || irreversibleProvenanceMissing;
4221
+
3994
4222
  if (!existsSync(parserPath)) {
3995
4223
  failures.push('parser.ts was not written');
4224
+ } else if (safetyBlocked) {
4225
+ const parserSource = readFileSync(parserPath, 'utf8');
4226
+ if (!/\bexport\s+(?:async\s+)?function\s+extract\b/.test(parserSource)) {
4227
+ failures.push('parser.ts must export `extract` function');
4228
+ }
3996
4229
  } else {
3997
4230
  try {
3998
4231
  const cacheBust = `?t=${Date.now()}`;
@@ -4029,9 +4262,17 @@ export async function externalVerification(
4029
4262
  }
4030
4263
 
4031
4264
  if (existsSync(parserTestPath)) {
4032
- const run = await runBunTestWithResults(parserTestPath, toolDir, 120000, {
4033
- [SESSION_PATH_ENV]: sessionPath,
4034
- });
4265
+ const run = await runBunTestWithResults(
4266
+ parserTestPath,
4267
+ toolDir,
4268
+ 120000,
4269
+ {
4270
+ [SESSION_PATH_ENV]: sessionPath,
4271
+ },
4272
+ {
4273
+ networkDisabled: safetyBlocked,
4274
+ },
4275
+ );
4035
4276
  if (run.exitCode !== 0) {
4036
4277
  failures.push(
4037
4278
  `bun test parser.test.ts exited ${run.exitCode}\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
@@ -4049,6 +4290,38 @@ export async function externalVerification(
4049
4290
  }
4050
4291
  }
4051
4292
 
4293
+ let workflowForRequestTests: ReturnType<typeof WorkflowSchema.parse> | undefined;
4294
+ try {
4295
+ workflowForRequestTests = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
4296
+ } catch {
4297
+ // The workflow schema failure above is the actionable error.
4298
+ }
4299
+ if (
4300
+ workflowForRequestTests &&
4301
+ requestsNeedingBodyEncodingDecision(workflowForRequestTests).length > 0
4302
+ ) {
4303
+ const src = existsSync(requestTestPath) ? readFileSync(requestTestPath, 'utf8') : undefined;
4304
+ failures.push(...requestEncodingTestContractFailures(workflowForRequestTests, src));
4305
+ if (src !== undefined) {
4306
+ const run = await runBunTestWithResults(
4307
+ requestTestPath,
4308
+ toolDir,
4309
+ 120000,
4310
+ {
4311
+ [SESSION_PATH_ENV]: sessionPath,
4312
+ },
4313
+ {
4314
+ networkDisabled: safetyBlocked,
4315
+ },
4316
+ );
4317
+ if (run.exitCode !== 0) {
4318
+ failures.push(
4319
+ `bun test request.test.ts exited ${run.exitCode}\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`,
4320
+ );
4321
+ }
4322
+ }
4323
+ }
4324
+
4052
4325
  // Run the live integration suite and classify the outcome. The per-param
4053
4326
  // coverage check below trusts the test *runner* (which named tests actually
4054
4327
  // ran green) rather than a static source scan, so a suite that was waived by
@@ -4056,7 +4329,11 @@ export async function externalVerification(
4056
4329
  const integrationTestPath = pathJoin(toolDir, 'integration.test.ts');
4057
4330
  let integrationOutcome: 'passed' | 'waived-bot' | 'waived-infra' | 'failed' | 'absent' = 'absent';
4058
4331
  let integrationPassedTests = new Set<string>();
4059
- if (!existsSync(integrationTestPath)) {
4332
+ if (safetyBlocked) {
4333
+ warnings.push(
4334
+ 'live integration suite omitted because the workflow declares an irreversible request',
4335
+ );
4336
+ } else if (!existsSync(integrationTestPath)) {
4060
4337
  failures.push(
4061
4338
  'integration.test.ts was not written — the tool must include a live API test that calls the workflow and verifies it returns real data',
4062
4339
  );
@@ -4070,6 +4347,11 @@ export async function externalVerification(
4070
4347
  warnings.push(
4071
4348
  'skipped the live integration test: a request references a ${state.X} capture (e.g. csrf/csp-nonce) whose pattern does not match the recorded page, so the live call is guaranteed to fail with STATE_MISSING. Fix the capture pattern/source (see the failure above) — the next verification cycle will run the live test once it can succeed. This avoids burning a doomed anti-bot .act call.',
4072
4349
  );
4350
+ } else if (irreversibleProvenanceMissing) {
4351
+ integrationOutcome = 'failed';
4352
+ warnings.push(
4353
+ 'skipped the live integration test until workflow.json preserves irreversible request provenance.',
4354
+ );
4073
4355
  } else {
4074
4356
  const integrationSrc = readFileSync(integrationTestPath, 'utf8');
4075
4357
  if (!hasLiveBaselineWorkflowTest(integrationSrc)) {
@@ -4199,6 +4481,7 @@ export async function externalVerification(
4199
4481
  // each is recorded in `paramVerification` as verified or not (with a reason),
4200
4482
  // and only a genuinely-uncovered param on a suite that DID run blocks compile.
4201
4483
  if (
4484
+ !safetyBlocked &&
4202
4485
  !referencedStateBroken &&
4203
4486
  existsSync(integrationTestPath) &&
4204
4487
  opts.likelyParams &&
@@ -4377,7 +4660,7 @@ export async function externalVerification(
4377
4660
  }
4378
4661
 
4379
4662
  const loadBearing = identifyLoadBearingRequests(session);
4380
- if (loadBearing.length > 0 && existsSync(parserPath)) {
4663
+ if (!safetyBlocked && loadBearing.length > 0 && existsSync(parserPath)) {
4381
4664
  const firstReq = loadBearing[0];
4382
4665
  if (firstReq?.response?.body) {
4383
4666
  try {
@@ -12,6 +12,7 @@ import {
12
12
  import { dirname, join as pathJoin } from 'node:path';
13
13
  import { loadBackendsCacheStatus } from './backend-cache.ts';
14
14
  import { runWorkflowWithLadder } from './backend-ladder.ts';
15
+ import { workflowHasIrreversibleEffect } from './effects.ts';
15
16
  import type { CredentialStore } from './runtime.ts';
16
17
  import { WorkflowSchema } from './types.ts';
17
18
 
@@ -55,10 +56,13 @@ function processIsAlive(pid: number): boolean {
55
56
  }
56
57
  }
57
58
 
58
- async function acquireSiteLiveLock(workflowPath: string): Promise<() => void> {
59
+ export async function acquireSiteLiveLock(
60
+ workflowPath: string,
61
+ deadlineMs = Date.now() + 10 * 60_000,
62
+ ): Promise<() => void> {
59
63
  const siteDir = dirname(dirname(workflowPath));
60
64
  const lockPath = pathJoin(siteDir, '.imprint-live-verification.lock');
61
- const deadline = Date.now() + 10 * 60_000;
65
+ const deadline = Math.min(deadlineMs, Date.now() + 10 * 60_000);
62
66
 
63
67
  while (Date.now() < deadline) {
64
68
  try {
@@ -122,6 +126,11 @@ export async function runCapturedIntegrationCase(opts: {
122
126
  preferredOnlyBackend?: boolean;
123
127
  }): Promise<LadderRun> {
124
128
  const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
129
+ if (workflowHasIrreversibleEffect(workflow)) {
130
+ throw new Error(
131
+ `Live integration is disabled for irreversible workflow ${JSON.stringify(workflow.toolName)}.`,
132
+ );
133
+ }
125
134
  const release = await acquireSiteLiveLock(opts.workflowPath);
126
135
  const startedAt = Date.now();
127
136
  let run: LadderRun;