@counterfact-labs/cli 0.2.3 → 0.2.5

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 (154) hide show
  1. package/README.md +7 -1
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/anthropic.js +11 -2
  4. package/dist/adapters/completion.js +3 -0
  5. package/dist/adapters/durable-write.js +9 -2
  6. package/dist/adapters/files.js +29 -13
  7. package/dist/adapters/git-ignore.js +28 -14
  8. package/dist/adapters/git.js +1 -1
  9. package/dist/adapters/gitignore-output.js +41 -0
  10. package/dist/adapters/models.js +1 -6
  11. package/dist/adapters/output-paths.js +5 -2
  12. package/dist/adapters/public-instructions.js +1 -1
  13. package/dist/adapters/relay-plan.js +4 -7
  14. package/dist/adapters/release.js +10 -4
  15. package/dist/adapters/repository-files.js +164 -4
  16. package/dist/adapters/repository-root.js +18 -2
  17. package/dist/adapters/results.js +8 -4
  18. package/dist/adapters/settings.js +27 -1
  19. package/dist/adapters/snapshot.js +86 -15
  20. package/dist/analysis-core/analysis/finding-audience-switch.js +1 -0
  21. package/dist/analysis-core/analysis/schemas.js +1 -1
  22. package/dist/analysis-core/analysis/source-notes/types.js +1 -1
  23. package/dist/analysis-core/chunks/377MLK4M.js +21 -0
  24. package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
  25. package/dist/analysis-core/chunks/{Y5AUKKYG.js → 3JWEFCTJ.js} +1 -1
  26. package/dist/analysis-core/chunks/{3227KXHF.js → 4H23CSUM.js} +1 -1
  27. package/dist/analysis-core/chunks/5DGPLXYB.js +1 -0
  28. package/dist/analysis-core/chunks/5TATKWLG.js +1 -0
  29. package/dist/analysis-core/chunks/{EH3QQ3T4.js → 6B2HILSY.js} +1 -1
  30. package/dist/analysis-core/chunks/6QY5BV5B.js +14 -0
  31. package/dist/analysis-core/chunks/6U2IR4XB.js +1 -0
  32. package/dist/analysis-core/chunks/A7WJJMRY.js +1 -0
  33. package/dist/analysis-core/chunks/C6X5GDG4.js +1 -0
  34. package/dist/analysis-core/chunks/DLPVYWHZ.js +1 -0
  35. package/dist/analysis-core/chunks/DZZBQDGO.js +1 -0
  36. package/dist/analysis-core/chunks/F3R4J3ND.js +1 -0
  37. package/dist/analysis-core/chunks/FOKDE64Y.js +1 -0
  38. package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
  39. package/dist/analysis-core/chunks/K2GNDX3W.js +1 -0
  40. package/dist/analysis-core/chunks/KTNA2KTN.js +7 -0
  41. package/dist/analysis-core/chunks/KWECQJ6X.js +1 -0
  42. package/dist/analysis-core/chunks/{SL27GMEN.js → M6HYSTQD.js} +1 -1
  43. package/dist/analysis-core/chunks/{VFENG4MR.js → MJGB25S5.js} +1 -1
  44. package/dist/analysis-core/chunks/{ZAA44GPI.js → N7U5N42V.js} +1 -1
  45. package/dist/analysis-core/chunks/NIOS7U6C.js +41 -0
  46. package/dist/analysis-core/chunks/{QPXNXMLC.js → OPMA32A6.js} +1 -1
  47. package/dist/analysis-core/chunks/PECX3OXB.js +1 -0
  48. package/dist/analysis-core/chunks/QFY6A3MS.js +66 -0
  49. package/dist/analysis-core/chunks/SKVEBOGA.js +1 -0
  50. package/dist/analysis-core/chunks/{VER355VN.js → STI4JE7L.js} +1 -1
  51. package/dist/analysis-core/chunks/SVMEWGD3.js +1 -0
  52. package/dist/analysis-core/chunks/X7WZAA7S.js +1 -0
  53. package/dist/analysis-core/chunks/YDBO7P4X.js +1 -0
  54. package/dist/analysis-core/chunks/YUY436SM.js +1 -0
  55. package/dist/analysis-core/chunks/ZTOVBAG7.js +1 -0
  56. package/dist/analysis-core/contracts/file-safety.js +1 -0
  57. package/dist/analysis-core/contracts/relay-analysis.js +1 -0
  58. package/dist/analysis-core/contracts/requests.js +1 -1
  59. package/dist/analysis-core/conventions/analysis-fit.js +1 -0
  60. package/dist/analysis-core/conventions/file-admission.js +1 -1
  61. package/dist/analysis-core/data/finding-types.js +1 -1
  62. package/dist/analysis-core/errors.js +1 -1
  63. package/dist/analysis-core/findings/sites.js +1 -0
  64. package/dist/analysis-core/github/repo-key.js +1 -1
  65. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  66. package/dist/analysis-core/index.js +3 -3
  67. package/dist/analysis-core/llm/cache-policy.js +1 -1
  68. package/dist/analysis-core/llm/cost.js +1 -1
  69. package/dist/analysis-core/llm/output-limit.js +1 -1
  70. package/dist/analysis-core/llm/types.js +1 -0
  71. package/dist/analysis-core/requests/answer-formats.js +1 -1
  72. package/dist/analysis-core/requests/headline-request.js +1 -1
  73. package/dist/analysis-core/requests/local-plan.js +1 -1
  74. package/dist/analysis-core/requests/registry.js +1 -1
  75. package/dist/analysis-core/requests/template-version.js +1 -1
  76. package/dist/analysis-core/requests/tools.js +1 -1
  77. package/dist/analysis-core/settings.js +1 -1
  78. package/dist/analysis-core/stages/engine-entry.js +1 -0
  79. package/dist/analysis-core/stages/execution-ports.js +1 -0
  80. package/dist/bundle/seal/deserialise.js +9 -1
  81. package/dist/bundle/seal/engine.js +76 -0
  82. package/dist/bundle/seal/freeze.js +9 -0
  83. package/dist/bundle/seal/metadata.js +3 -0
  84. package/dist/bundle/seal/open-engine.js +26 -0
  85. package/dist/bundle/seal/open.js +2 -8
  86. package/dist/bundle/seal/signature.js +2 -2
  87. package/dist/cli/commands/analyze.js +39 -5
  88. package/dist/cli/commands/finding.js +9 -3
  89. package/dist/cli/context.js +4 -1
  90. package/dist/cli/development-relay.js +34 -0
  91. package/dist/cli/finding-display.js +39 -0
  92. package/dist/cli/finding-lookup.js +92 -0
  93. package/dist/cli/finding-settings.js +5 -0
  94. package/dist/cli/fix-requests.js +42 -0
  95. package/dist/cli/intake-payload.js +19 -11
  96. package/dist/cli/main.js +9 -4
  97. package/dist/cli/mcp.js +65 -18
  98. package/dist/cli/relay-check.js +47 -14
  99. package/dist/cli/relay-preparation.js +31 -6
  100. package/dist/cli/result-check.js +68 -0
  101. package/dist/cli/summary.js +10 -3
  102. package/dist/contract/agent-choice.js +15 -0
  103. package/dist/contract/agent-proposal.js +38 -0
  104. package/dist/contract/attestation.js +106 -0
  105. package/dist/contract/cli-refusals.js +5 -0
  106. package/dist/contract/cli-version.js +1 -1
  107. package/dist/contract/console.js +41 -16
  108. package/dist/contract/engine.js +44 -0
  109. package/dist/contract/findings.js +28 -0
  110. package/dist/contract/fix-requests.js +30 -0
  111. package/dist/contract/messages.js +6 -94
  112. package/dist/contract/public-report.js +1 -0
  113. package/dist/contract/runtime-environment.js +12 -1
  114. package/dist/core/analysis/run-to-end.js +7 -52
  115. package/dist/gatekeeper/attestation.js +1 -1
  116. package/dist/gatekeeper/channel-binding.js +15 -0
  117. package/dist/gatekeeper/cli-api.js +12 -2
  118. package/dist/gatekeeper/connection-exchange.js +18 -13
  119. package/dist/gatekeeper/console.js +11 -4
  120. package/dist/gatekeeper/model.js +2 -1
  121. package/dist/gatekeeper/production-attestation.js +23 -17
  122. package/dist/gatekeeper/trust/policy.js +4 -1
  123. package/dist/gatekeeper/trust/schema.js +4 -0
  124. package/dist/transport/kms.js +25 -0
  125. package/package.json +4 -3
  126. package/dist/adapters/quality.js +0 -6
  127. package/dist/adapters/report.js +0 -29
  128. package/dist/analysis-core/analysis/analysis-flags.js +0 -1
  129. package/dist/analysis-core/analysis/finding-quality/config.js +0 -1
  130. package/dist/analysis-core/analysis/finding-quality/policy.js +0 -1
  131. package/dist/analysis-core/analysis/headlines/attachment.js +0 -1
  132. package/dist/analysis-core/analysis/headlines/input.js +0 -1
  133. package/dist/analysis-core/chunks/6YCABS5T.js +0 -1
  134. package/dist/analysis-core/chunks/7QDOHWSY.js +0 -27
  135. package/dist/analysis-core/chunks/BZNHHLNP.js +0 -1
  136. package/dist/analysis-core/chunks/DTMHXKP6.js +0 -7
  137. package/dist/analysis-core/chunks/EQTIRIMU.js +0 -37
  138. package/dist/analysis-core/chunks/G322LST6.js +0 -1
  139. package/dist/analysis-core/chunks/IL2KRP3T.js +0 -1
  140. package/dist/analysis-core/chunks/JF4GYMOZ.js +0 -1
  141. package/dist/analysis-core/chunks/KEKTZDIK.js +0 -1
  142. package/dist/analysis-core/chunks/M7MMAUAK.js +0 -1
  143. package/dist/analysis-core/chunks/OFBJNHC6.js +0 -1
  144. package/dist/analysis-core/chunks/ORCYIIHB.js +0 -60
  145. package/dist/analysis-core/chunks/PVPEY7NF.js +0 -1
  146. package/dist/analysis-core/chunks/STZOC7LN.js +0 -1
  147. package/dist/analysis-core/chunks/T5TREZCQ.js +0 -1
  148. package/dist/analysis-core/chunks/TDNTU4TF.js +0 -1
  149. package/dist/analysis-core/chunks/U5HOEVPE.js +0 -1
  150. package/dist/analysis-core/chunks/UEWL3GMQ.js +0 -1
  151. package/dist/analysis-core/chunks/YVDCJN7D.js +0 -14
  152. package/dist/analysis-core/chunks/ZMUBMYWN.js +0 -1
  153. package/dist/core/analysis/finding-quality/config.js +0 -4
  154. package/dist/core/analysis/structural-run.js +0 -16
@@ -1,12 +1,14 @@
1
- import { resolveFolderPath } from "../adapters/folder-path.js";
2
- import { savedAnalysisUrl } from "../contract/console-url.js";
3
1
  import { randomUUID } from "node:crypto";
4
2
  import { disabledSetupPings } from "../setup-pings/client.js";
5
3
  import { saveCompletion, loadCompletion } from "../adapters/completion.js";
6
4
  import { saveLocalResults } from "../adapters/results.js";
5
+ import { GITIGNORE_OUTPUT_NOTICE } from "../adapters/gitignore-output.js";
7
6
  import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
8
7
  import { validateClientRelease } from "../adapters/release.js";
9
8
  import { selectAnalysisExecution } from "../adapters/models.js";
9
+ import { resolveFolderPath } from "../adapters/folder-path.js";
10
+ import { AgentChoiceError } from "../contract/agent-choice.js";
11
+ import { savedAnalysisUrl } from "../contract/console-url.js";
10
12
  import { prepareRelayCheck } from "./relay-preparation.js";
11
13
  import { cliRefusals, COMMITTED_FILES_MESSAGE, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
12
14
  import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
@@ -14,9 +16,13 @@ import { GatekeeperClient } from "../gatekeeper/client.js";
14
16
  import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
15
17
  import productionTrust from "../gatekeeper/trust/production.json" with { type: "json" };
16
18
  import { runAnalysisToEnd } from "../core/analysis/run-to-end.js";
19
+ import { findingPresentation } from "../analysis-core/findings/presentation.js";
17
20
  import { formatSummary } from "./summary.js";
18
21
  import { buildIntakePayload } from "./intake-payload.js";
19
22
  import { completionRecoveryDecision, COMPLETION_SETTINGS_MISMATCH_MESSAGE, matchesCheck } from "./completion-recovery.js";
23
+ import { savedFindings } from "./finding-display.js";
24
+ import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
25
+ import { readFindingSettings } from "./finding-settings.js";
20
26
  const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
21
27
  function requireIntakeReservation(status) {
22
28
  if (status.state === "reserved")
@@ -31,7 +37,8 @@ function requireIntakeReservation(status) {
31
37
  async function submit(client, payload, signal) {
32
38
  signal.throwIfAborted();
33
39
  const status = await client.status(payload.reservationId);
34
- const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds, settlement: value.settlement });
40
+ const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds,
41
+ ...(value.findings ? { findings: value.findings } : {}), settlement: value.settlement });
35
42
  if (!matchesCheck(status, payload))
36
43
  throw new ConsoleError("check_conflict");
37
44
  if (status.uploadState === "saved")
@@ -65,11 +72,14 @@ export function runReservedRelayCheck(folder, options) {
65
72
  return runRelayLifecycle(folder, options, options);
66
73
  }
67
74
  async function runRelayLifecycle(folder, options, existing) {
68
- const { config, settings, stdout, stderr } = options;
75
+ const { config, stdout, stderr } = options;
76
+ let settings = options.settings;
77
+ const summaryOptions = { audienceEnabled: false };
69
78
  let client, reservationId, projectId;
70
79
  let connection = existing?.connection;
71
80
  let identity;
72
81
  let completedPayload;
82
+ let displayFindings;
73
83
  let completionOrigin;
74
84
  let completed = false, uploaded = false, heartbeat;
75
85
  let renewal;
@@ -77,7 +87,8 @@ async function runRelayLifecycle(folder, options, existing) {
77
87
  const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
78
88
  const showSaved = (result) => {
79
89
  const url = savedAnalysisUrl(result.projectId, config.CF_CONSOLE_URL);
80
- options.onSaved?.({ ...result, url });
90
+ const findings = savedFindings({ findingIds: result.findingIds ?? [], findings: result.findings }, displayFindings ?? completedPayload?.findings ?? []);
91
+ options.onSaved?.({ ...result, findings, url, audienceEnabled: summaryOptions.audienceEnabled });
81
92
  stdout(`Saved analysis ${result.analysisId}: ${url}\n`);
82
93
  };
83
94
  try {
@@ -100,6 +111,8 @@ async function runRelayLifecycle(folder, options, existing) {
100
111
  // Signature verification precedes all folder reads, status calls and spending.
101
112
  const validated = validateClientRelease(options.release, settings);
102
113
  const { bundle } = validated;
114
+ settings = validated.settings;
115
+ summaryOptions.audienceEnabled = readFindingSettings(settings).audienceEnabled;
103
116
  folder = await resolveFolderPath(folder);
104
117
  signal.throwIfAborted();
105
118
  const prior = await loadCompletion(folder);
@@ -134,10 +147,10 @@ async function runRelayLifecycle(folder, options, existing) {
134
147
  if (error.code !== "ENOENT")
135
148
  throw error;
136
149
  }
137
- const { source, provenance, plan, start } = await prepareRelayCheck(folder, { ...options, signal, expectedCommit: existing?.expectedCommit }, validated);
150
+ const { source, provenance, plan, start, startMetadata } = await prepareRelayCheck(folder, { ...options, signal, expectedCommit: existing?.expectedCommit, expectedFiles: existing?.expectedFiles }, validated);
138
151
  let reservation = existing?.reservation;
139
152
  if (!reservation) {
140
- const started = await client.start(start);
153
+ const started = await client.start(start, startMetadata);
141
154
  projectId = started.projectId;
142
155
  signal.throwIfAborted();
143
156
  const created = await client.create({ ...started, checkId: randomUUID(), plan });
@@ -171,9 +184,28 @@ async function runRelayLifecycle(folder, options, existing) {
171
184
  (options.setupPings ?? disabledSetupPings).ping("first_analysis_started");
172
185
  const report = await runAnalysisToEnd(source, execution.model, settings, { execution });
173
186
  signal.throwIfAborted();
174
- const paths = await saveLocalResults(folder, report, source.skippedFiles, options.outputDir);
175
- const payload = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
176
- bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report);
187
+ const paths = await saveLocalResults(folder, report, source.skippedFiles, options.outputDir, { ...summaryOptions, settings });
188
+ try {
189
+ options.onLocalSaved?.(paths);
190
+ if (paths.ignoreAdded)
191
+ stderr(GITIGNORE_OUTPUT_NOTICE);
192
+ }
193
+ catch { /* Output failure must not change a server outcome. */ }
194
+ const { payload, resultCheck, findingIndexes } = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
195
+ bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report, { settings, files: source.snapshot });
196
+ displayFindings = findingIndexes.map(index => {
197
+ const finding = report.findings[index];
198
+ return { title: findingPresentation(finding).displayTitle, audience: finding.audience };
199
+ });
200
+ if (resultCheck?.droppedFindings) {
201
+ const count = resultCheck.droppedFindings;
202
+ const reason = resultCheck.schemaDroppedFindings || resultCheck.capDroppedFindings
203
+ ? `${count === 1 ? "it was" : "they were"} too long or not in the expected format` : `${count === 1 ? "its" : "their"} quotes did not match your files`;
204
+ try {
205
+ stderr(`${count} ${count === 1 ? "finding was" : "findings were"} not uploaded because ${reason}.\n`);
206
+ }
207
+ catch { /* Output failure must not change a server outcome. */ }
208
+ }
177
209
  completionOrigin = new URL(config.CF_CONSOLE_URL).origin;
178
210
  await saveCompletion(folder, payload, completionOrigin);
179
211
  completedPayload = payload;
@@ -181,7 +213,7 @@ async function runRelayLifecycle(folder, options, existing) {
181
213
  const saved = await submit(client, payload, signal);
182
214
  uploaded = true;
183
215
  await saveCompletion(folder, payload, completionOrigin, true);
184
- stdout(formatSummary(report));
216
+ stdout(formatSummary(report, summaryOptions));
185
217
  stdout(`\nSaved ${paths.findingsPath}\nSaved ${paths.summaryPath}\n`);
186
218
  showSaved(saved);
187
219
  return 0;
@@ -204,7 +236,7 @@ async function runRelayLifecycle(folder, options, existing) {
204
236
  catch { /* Committed server state still survives a local write failure. */ }
205
237
  }
206
238
  try {
207
- showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds });
239
+ showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds, findings: status.findings });
208
240
  }
209
241
  catch { /* Output failure must not change a server outcome. */ }
210
242
  return 0;
@@ -216,11 +248,12 @@ async function runRelayLifecycle(folder, options, existing) {
216
248
  }
217
249
  catch { /* Keep the pending record if the durable write cannot be confirmed. */ }
218
250
  }
219
- const known = error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE].includes(error.message)
251
+ const known = error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
220
252
  || error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
221
253
  const message = known ? error.message : FINISH_FAILURE_MESSAGE;
222
254
  try {
223
- options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message });
255
+ options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message,
256
+ ...(error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
224
257
  stderr(`${message}\n`);
225
258
  if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
226
259
  stderr(stateLine(status));
@@ -1,31 +1,56 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join, relative, sep } from "node:path";
1
3
  import { outsideLoadersEnabled } from "../analysis-core/settings.js";
4
+ import { resolveFolderPath } from "../adapters/folder-path.js";
2
5
  import { readLocalFolder } from "../adapters/files.js";
3
- import { snapshotProvenance } from "../adapters/snapshot.js";
6
+ import { snapshotProvenance, sameFileHashes } from "../adapters/snapshot.js";
4
7
  import { validateClientRelease } from "../adapters/release.js";
5
8
  import { prepareRelayPlan } from "../adapters/relay-plan.js";
9
+ import { AgentChoiceError } from "../contract/agent-choice.js";
6
10
  import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
11
+ import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
7
12
  import { repositoryFacts } from "./git.js";
8
13
  /** Absolute checkout paths stay local; repository facts own the start's commit. */
9
14
  export function buildRelayStartPayload(facts, files) {
10
15
  const { remoteUrl, relPath, folderName, branch, commit, dirty } = facts;
11
16
  return { execution: "local", remoteUrl, relPath, folderName, branch, commit, dirty, files };
12
17
  }
18
+ export function committedFilesOnly(settings) {
19
+ return settings.CF_LOCAL_COMMITTED_ONLY === "1";
20
+ }
13
21
  /** Recovery can reuse signature validation without reading the folder again. */
14
22
  export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
15
- const { bundle, tools } = validated;
23
+ const { bundle, tools, settings } = validated;
16
24
  options.signal?.throwIfAborted();
17
25
  // Discovery must agree with Git before publishing repository-relative captures.
18
26
  const earlyFacts = outsideLoadersEnabled(options.settings) ? await (options.facts ?? repositoryFacts)(folder) : undefined;
19
27
  options.signal?.throwIfAborted();
20
28
  const read = await readLocalFolder(options.settings, folder, options.trialAdmission, { captureSnapshot: true,
29
+ proposal: options.proposal,
21
30
  ...(earlyFacts ? { expectedRepositoryRoot: earlyFacts.repoRoot ?? null } : {}) });
31
+ if (read.proposalCounts?.fromProposal === 0 && read.proposalCounts.rejected > 0)
32
+ throw new Error(INVALID_PROPOSAL_MESSAGE);
33
+ if (read.agentChoice) {
34
+ const cwd = await resolveFolderPath(options.cwd ?? process.cwd());
35
+ const launchPath = (path) => relative(cwd, join(read.sourceBase ?? folder, path)).split(sep).join("/") || ".";
36
+ throw new AgentChoiceError({ ...read.agentChoice,
37
+ candidates: read.agentChoice.candidates.map(candidate => ({
38
+ folder: launchPath(candidate.folder), reason: launchPath(candidate.reason),
39
+ })),
40
+ });
41
+ }
22
42
  const source = { ...read, files: Object.freeze({ ...read.files }) };
23
- const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, source.sourceBase ? { selectionFolder: folder } : undefined);
24
- if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
43
+ const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, { selectionFolder: folder, committedOnly: committedFilesOnly(options.settings) });
44
+ if (options.expectedCommit === null) {
45
+ if (!sameFileHashes(options.expectedFiles ?? [], source.snapshot))
46
+ throw new Error(COMMITTED_FILES_MESSAGE);
47
+ }
48
+ else if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
25
49
  throw new Error(COMMITTED_FILES_MESSAGE);
26
- const { plan, files } = prepareRelayPlan({ bundle, tools, settings: options.settings, relayVersion: options.release.relayVersion, source });
50
+ const { plan, files } = prepareRelayPlan({ bundle, tools, settings, relayVersion: options.release.relayVersion, source });
27
51
  options.signal?.throwIfAborted();
28
52
  const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(folder);
29
53
  options.signal?.throwIfAborted();
30
- return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase ? { ...facts, relPath: "" } : facts, files) };
54
+ const startMetadata = { analysisSize: source.analysisSize, startAttempt: randomUUID() };
55
+ return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase ? { ...facts, relPath: "" } : facts, files), startMetadata };
31
56
  }
@@ -0,0 +1,68 @@
1
+ import { publicFinding, resultCheckFinding, RESULT_CHECK_LIMITS } from "../analysis-core/contracts/relay-analysis.js";
2
+ import { findingSitesInOrder } from "../analysis-core/findings/sites.js";
3
+ import { localFindingSchema } from "../contract/console.js";
4
+ function evidenceQuotes(finding, repoKey) {
5
+ const primary = finding.site ? { repo: finding.siteRepo ?? repoKey, ...finding.site, excerpt: finding.excerpt } : null;
6
+ const sites = findingSitesInOrder(primary, finding.otherSites);
7
+ const quotes = [];
8
+ function add(site, text) {
9
+ if (typeof text === "string")
10
+ quotes.push({ repo: site.repo, path: site.path, text });
11
+ }
12
+ add(primary ?? {}, finding.excerpt);
13
+ finding.otherSites.forEach(site => add(site, site.excerpt));
14
+ for (const note of finding.sourceNotes?.sites ?? []) {
15
+ if (note.keyPhrase !== "")
16
+ add(sites[note.index] ?? {}, note.keyPhrase);
17
+ }
18
+ for (const site of finding.sourceNotes?.counterEvidence ?? [])
19
+ add(site, site.excerpt);
20
+ return quotes;
21
+ }
22
+ /** The intake boundary and offline measurement use exactly the same evidence decision. */
23
+ export function checkIntakeFindings(findings, context, onInvalidQuote) {
24
+ const accepted = [], acceptedIndexes = [];
25
+ const originals = new Map();
26
+ function original(path) {
27
+ if (!originals.has(path)) {
28
+ const captured = context.files[path];
29
+ try {
30
+ originals.set(path, typeof captured === "string" ? captured : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(captured));
31
+ }
32
+ catch {
33
+ originals.set(path, null);
34
+ }
35
+ }
36
+ return originals.get(path);
37
+ }
38
+ let quotes = 0, droppedQuotes = 0, invalidQuotes = 0, schemaDroppedFindings = 0, capDroppedFindings = 0;
39
+ findings.forEach((finding, index) => {
40
+ const parsed = publicFinding.safeParse(finding);
41
+ const evidence = parsed.success ? evidenceQuotes(parsed.data, context.repoKey) : [];
42
+ quotes += evidence.length;
43
+ const invalid = evidence.filter(quote => {
44
+ const source = quote.repo === context.repoKey && typeof quote.path === "string" && Object.hasOwn(context.files, quote.path)
45
+ ? original(quote.path) : null;
46
+ const invalid = !quote.text.length || source === null || !source.includes(quote.text);
47
+ if (invalid)
48
+ onInvalidQuote?.({ text: quote.text, source });
49
+ return invalid;
50
+ }).length;
51
+ invalidQuotes += invalid;
52
+ const schemaValid = parsed.success && resultCheckFinding.safeParse(parsed.data).success && localFindingSchema.safeParse(parsed.data).success;
53
+ if (!schemaValid)
54
+ schemaDroppedFindings++;
55
+ if (index >= RESULT_CHECK_LIMITS.findings)
56
+ capDroppedFindings++;
57
+ if (!schemaValid || !parsed.success || invalid || index >= RESULT_CHECK_LIMITS.findings) {
58
+ // Removing one supporting quote could change the claim or invalidate source-note indices.
59
+ droppedQuotes += evidence.length;
60
+ }
61
+ else {
62
+ accepted.push(parsed.data);
63
+ acceptedIndexes.push(index);
64
+ }
65
+ });
66
+ return { findings: accepted, acceptedIndexes, droppedFindings: findings.length - accepted.length, quotes, droppedQuotes, invalidQuotes,
67
+ schemaDroppedFindings, capDroppedFindings };
68
+ }
@@ -1,12 +1,19 @@
1
1
  import { findingPresentation } from "../analysis-core/findings/presentation.js";
2
- export function formatSummary(report) {
2
+ import { findingDisplayRows, TEAM_WORKFLOW_HEADING } from "./finding-display.js";
3
+ export function formatSummary(report, options) {
3
4
  const lines = ["# Counterfact analysis", "",
4
5
  `${report.findings.length} findings; ${report.structures.length} structural proposals.`, ""];
5
6
  if (!report.findings.length)
6
7
  lines.push("No findings.", "");
7
- for (const [index, finding] of report.findings.entries()) {
8
+ const findings = report.findings.map(finding => {
8
9
  const presentation = findingPresentation(finding);
9
- lines.push(`## ${index + 1}. ${presentation.displayTitle}`, "");
10
+ return { ...finding, presentation, title: presentation.displayTitle };
11
+ });
12
+ for (const { finding, label, startsTeamGroup } of findingDisplayRows(findings, { ...options, labels: "position" })) {
13
+ const { presentation } = finding;
14
+ if (startsTeamGroup)
15
+ lines.push(`## ${TEAM_WORKFLOW_HEADING}`, "");
16
+ lines.push(`## ${label}`, "");
10
17
  if (presentation.ifLeft)
11
18
  lines.push(`If left: ${presentation.ifLeft}`, "");
12
19
  lines.push(`Severity: ${finding.severity}; category: ${finding.category ?? "unclassified"}.`, "", finding.cause, "");
@@ -0,0 +1,15 @@
1
+ export const CHOOSE_AGENT_MESSAGE = "Counterfact couldn't tell which folder holds your agent. Run it again on that folder, for example";
2
+ export const NO_AGENT_MESSAGE = "Counterfact couldn't find an agent prompt in this folder. Pass a folder containing an agent prompt or a model call with a system prompt.";
3
+ /** The local importer supplies the choice; transport keeps its data and wording together. */
4
+ export class AgentChoiceError extends Error {
5
+ agentChoice;
6
+ constructor(agentChoice) {
7
+ const folders = [...new Set(agentChoice.candidates.map(candidate => candidate.folder))];
8
+ super(agentChoice.kind === "choose_agent" ? [`${CHOOSE_AGENT_MESSAGE} counterfact-labs analyze ${shellArgument(folders[0])}.`, "These folders look like agents:", ...folders].join("\n") : NO_AGENT_MESSAGE);
9
+ this.agentChoice = agentChoice;
10
+ }
11
+ }
12
+ /** Folder names must remain literal when a user pastes the suggested command. */
13
+ function shellArgument(folder) {
14
+ return /^[a-zA-Z0-9_./-]+$/.test(folder) ? folder : `'${folder.replaceAll("'", "'\\''")}'`;
15
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { importProposalEnabled } from "../analysis-core/index.js";
3
+ export const INVALID_PROPOSAL_MESSAGE = "The proposed agent files couldn't be used, because a path is outside the folder or isn't a file Counterfact can read.";
4
+ export const INVALID_PROPOSAL_FORMAT_MESSAGE = "The proposed agent files couldn't be used, because the proposal needs an agent name and a list of files with supported roles.";
5
+ // Check both path separators so a proposal has the same meaning on every host.
6
+ export const agentProposalSchema = z.object({
7
+ agentName: z.string().min(1).max(200).regex(/^[^\p{C}\p{Zl}\p{Zp}]+$/u),
8
+ files: z.array(z.object({
9
+ path: z.string().min(1).max(512).regex(/^(?![A-Za-z]:)(?![/\\])(?!.*(?:^|[/\\])\.\.(?:[/\\]|$))[^\p{C}\p{Zl}\p{Zp}]+$/u)
10
+ .describe("Paths are relative to the agent folder."),
11
+ role: z.enum(["prompt", "tools", "model-calling code", "config", "other"]),
12
+ })).max(200),
13
+ });
14
+ export const agentProposalInputSchema = z.toJSONSchema(agentProposalSchema);
15
+ delete agentProposalInputSchema.$schema;
16
+ function validateAgentProposal(value) {
17
+ if (value === undefined)
18
+ return undefined;
19
+ const parsed = agentProposalSchema.safeParse(value);
20
+ if (!parsed.success) {
21
+ const invalidPath = parsed.error.issues.some(issue => issue.path[0] === "files" && issue.path[2] === "path");
22
+ throw new Error(invalidPath ? INVALID_PROPOSAL_MESSAGE : INVALID_PROPOSAL_FORMAT_MESSAGE);
23
+ }
24
+ return parsed.data;
25
+ }
26
+ /** Check the switch before invoking a loader so disabled proposals never open files. */
27
+ export async function loadAgentProposal(load, settings) {
28
+ if (!importProposalEnabled(settings))
29
+ return undefined;
30
+ let value;
31
+ try {
32
+ value = await load();
33
+ }
34
+ catch {
35
+ throw new Error("The proposal file couldn't be read as JSON.");
36
+ }
37
+ return validateAgentProposal(value);
38
+ }
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+ import { allowsRuntimeOverrides, checkRuntimeEnvironment } from "./runtime-environment.js";
3
+ // Identity contracts stay independent of analysis requests so probes cannot load private rules.
4
+ export const compactJwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
5
+ export const hexChallenge = z.string().length(64).regex(/^[0-9a-f]+$/);
6
+ export const PRODUCTION_EVIDENCE_VERSION = 1;
7
+ export const RELAY_CHANNEL_AUDIENCE = "https://relay.counterfactlabs.com";
8
+ // Passthrough forwarding keeps the non-root workload's listener port unchanged.
9
+ export const RELAY_PORT = 8443;
10
+ export const RELAY_URL = `${RELAY_CHANNEL_AUDIENCE}:${RELAY_PORT}`;
11
+ export const RELAY_HEALTH_PATH = "/health";
12
+ export const RELAY_HEALTH_URL = `${RELAY_URL}${RELAY_HEALTH_PATH}`;
13
+ export const GOOGLE_ATTESTATION_ISSUER = "https://confidentialcomputing.googleapis.com";
14
+ export const MAX_ATTESTATION_BYTES = 32 * 1024;
15
+ export const attestationChallenge = z.object({
16
+ version: z.literal(PRODUCTION_EVIDENCE_VERSION),
17
+ challenge: hexChallenge,
18
+ }).strict();
19
+ export const productionEvidence = z.object({
20
+ version: z.literal(PRODUCTION_EVIDENCE_VERSION),
21
+ token: z.string().max(MAX_ATTESTATION_BYTES).regex(compactJwtPattern),
22
+ }).strict();
23
+ /** Google's launcher accepts up to six caller nonces, each at most 74 characters. */
24
+ export const evidenceBinding = z.object({
25
+ audience: z.string().min(1).max(256).regex(/^[A-Za-z0-9:/._-]+$/),
26
+ nonces: z.array(z.string().min(1).max(74).regex(/^[A-Za-z0-9_:-]+$/)).min(1).max(6),
27
+ }).strict();
28
+ /** Native claims are interpreted only after the PKI chain and token signature are authenticated. */
29
+ export const googleAttestationClaims = z.object({
30
+ iss: z.literal(GOOGLE_ATTESTATION_ISSUER),
31
+ aud: z.string(),
32
+ sub: z.string(),
33
+ iat: z.number().int(),
34
+ nbf: z.number().int(),
35
+ exp: z.number().int(),
36
+ eat_nonce: z.union([z.string(), z.array(z.string()).min(1).max(6)]),
37
+ secboot: z.literal(true),
38
+ dbgstat: z.literal("disabled-since-boot"),
39
+ oemid: z.literal(11129),
40
+ hwmodel: z.string(),
41
+ swname: z.literal("CONFIDENTIAL_SPACE"),
42
+ swversion: z.array(z.string()).length(1),
43
+ google_service_accounts: z.array(z.string()).min(1),
44
+ development: z.never().optional(),
45
+ identity: z.never().optional(),
46
+ submods: z.object({
47
+ confidential_space: z.object({
48
+ support_attributes: z.array(z.string()),
49
+ monitoring_enabled: z.object({ memory: z.literal(false) }).passthrough(),
50
+ }).passthrough(),
51
+ container: z.object({
52
+ image_digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
53
+ image_signatures: z.array(z.object({
54
+ key_id: z.string(), signature: z.string().min(1),
55
+ signature_algorithm: z.literal("RSASSA_PSS_SHA256"),
56
+ }).passthrough()).min(1),
57
+ // Google omits override claims when nothing was overridden; only the owned clock rollback setting is allowed.
58
+ cmd_override: z.array(z.unknown()).length(0).optional(),
59
+ env_override: z.record(z.string(), z.string()).refine(allowsRuntimeOverrides).optional(),
60
+ // Real tokens list the container's own environment; the relay's start-up rule decides which settings are allowed.
61
+ env: z.record(z.string(), z.string()).refine(env => {
62
+ try {
63
+ checkRuntimeEnvironment(env);
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }),
70
+ }).passthrough(),
71
+ }).passthrough(),
72
+ }).passthrough();
73
+ /** Authenticated observations describe a refused workload without granting it an admitted identity. */
74
+ export const observedAttestationClaims = googleAttestationClaims.extend({
75
+ dbgstat: z.string(),
76
+ submods: googleAttestationClaims.shape.submods.extend({
77
+ container: googleAttestationClaims.shape.submods.shape.container.extend({
78
+ image_signatures: z.array(googleAttestationClaims.shape.submods.shape.container.shape.image_signatures.element),
79
+ cmd_override: z.array(z.unknown()).optional(),
80
+ env_override: z.record(z.string(), z.unknown()).optional(),
81
+ }),
82
+ }),
83
+ });
84
+ // Intel TDX omits these arrays for unsigned/debug images; only probes may read absence as "none".
85
+ export const probeAttestationClaims = observedAttestationClaims.extend({
86
+ submods: observedAttestationClaims.shape.submods.extend({
87
+ confidential_space: observedAttestationClaims.shape.submods.shape.confidential_space.extend({
88
+ support_attributes: observedAttestationClaims.shape.submods.shape.confidential_space.shape.support_attributes.default([]),
89
+ }),
90
+ container: observedAttestationClaims.shape.submods.shape.container.extend({
91
+ image_signatures: observedAttestationClaims.shape.submods.shape.container.shape.image_signatures.default([]),
92
+ }),
93
+ }),
94
+ });
95
+ /**
96
+ * Local development claims. These cannot establish production identity; production uses native Google claims.
97
+ */
98
+ export const attestationClaims = z.object({
99
+ iss: z.string().url(),
100
+ /** The relay image's digest. Must be one we published. */
101
+ imageDigest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
102
+ /** Hash of the relay's channel public key, so the token is bound to this connection. */
103
+ channelKeyHash: z.string().regex(/^[0-9a-f]{64}$/),
104
+ exp: z.number().int(),
105
+ iat: z.number().int(),
106
+ }).passthrough();
@@ -1,15 +1,20 @@
1
+ import { FOLDER_TOO_LARGE_MESSAGE } from "../analysis-core/errors.js";
1
2
  export const START_FAILURE_MESSAGE = "This couldn't start. Try again.";
2
3
  export const RESERVATION_EXPIRED_MESSAGE = "This reservation expired. Start a new analysis.";
3
4
  export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again.";
4
5
  export const RESULTS_TOO_LARGE_MESSAGE = "The results were too large to save. Try again.";
5
6
  export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed version of the agent's files. Commit your changes, then try again.";
6
7
  const unsupportedVersion = "This version of Counterfact can't run analyses. Update it: npm install -g @counterfact-labs/cli@latest.";
8
+ // The console separates over-limit costs from version or route mismatches, so each code gets its own recovery guidance.
9
+ const analysisTooLarge = "This analysis is too large to run at once. Choose a smaller folder, such as one agent's folder.";
7
10
  /** Shared public refusals keep mode admission and reservation errors consistent. */
8
11
  export const cliRefusals = {
12
+ folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
9
13
  low_balance: "Your balance doesn't cover this analysis. Add credit at https://app.counterfactlabs.com/account?status=low_balance#balance",
10
14
  local_checks_paused: "Analyses are paused right now. Try again later.",
11
15
  cli_update_required: "Update Counterfact: run npm install -g @counterfact-labs/cli@latest.",
12
16
  release_unavailable: unsupportedVersion,
17
+ plan_over_limit: analysisTooLarge,
13
18
  invalid_plan: unsupportedVersion,
14
19
  invalid_body: "Counterfact couldn't read this request. Update Counterfact, then try again.",
15
20
  body_too_large: "This request is too large. The limit is 2 MB.",
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.2.3";
1
+ export const CLI_VERSION = "0.2.5";
@@ -1,14 +1,35 @@
1
1
  import { z } from "zod";
2
- import { fixDeltaSchema, fixFactsSchema } from "../analysis-core/data/finding-types.js";
2
+ import { findingAudienceSchema, fixDeltaSchema, fixFactsSchema } from "../analysis-core/data/finding-types.js";
3
3
  import { inspectFixSchema, otherSiteSchema, rawFindingSchema } from "../analysis-core/analysis/schemas.js";
4
4
  import { sourceNotesSchema } from "../analysis-core/analysis/source-notes/types.js";
5
+ import { parseAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
6
+ import { canonicalUuidSchema as uuid, matchingFindingNumbers, numberedFindingsSchema } from "./findings.js";
7
+ import { RESULT_CHECK_LIMITS, validIntakeText } from "../analysis-core/contracts/relay-analysis.js";
5
8
  export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
6
- const uuid = z.uuid().transform(value => value.toLowerCase());
7
9
  const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
8
10
  const count = (max) => z.number().int().positive().max(max);
9
11
  // These data-only boundaries follow the SaaS local start, reservation and findings owners; see docs/provenance.md.
10
12
  export const LOCAL_START_EXECUTION_HEADER = { name: "x-counterfact-execution", value: "local" };
11
13
  export const LOCAL_START_BODY_LIMIT = 2 * 1024 * 1024;
14
+ export const ANALYSIS_SIZE_HEADER = "x-counterfact-analysis-size";
15
+ export const START_ATTEMPT_HEADER = "x-counterfact-start-attempt";
16
+ export const ANALYSIS_SIZE_HEADER_LIMIT = 256;
17
+ /** Additive headers let older consoles keep accepting the unchanged, strict start body. */
18
+ export function serializeLocalStartMetadata(metadata) {
19
+ const size = JSON.stringify(parseAnalysisSize(metadata.analysisSize));
20
+ if (Buffer.byteLength(size, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
21
+ throw new Error("Invalid analysis size header");
22
+ return { [ANALYSIS_SIZE_HEADER]: size, [START_ATTEMPT_HEADER]: uuid.parse(metadata.startAttempt) };
23
+ }
24
+ /** Missing measurements preserve old clients; present measurements must be complete. */
25
+ export function parseLocalStartMetadata(headers) {
26
+ const raw = headers.get(ANALYSIS_SIZE_HEADER);
27
+ if (raw === null)
28
+ return undefined;
29
+ if (Buffer.byteLength(raw, "utf8") > ANALYSIS_SIZE_HEADER_LIMIT)
30
+ throw new Error("Invalid analysis size header");
31
+ return { analysisSize: parseAnalysisSize(JSON.parse(raw)), startAttempt: uuid.parse(headers.get(START_ATTEMPT_HEADER)) };
32
+ }
12
33
  const localStartPath = z.string().min(1).max(4096).refine(value => !value.includes("\\") && !value.includes("\0") && !value.startsWith("/")
13
34
  && value.split("/").every(part => part !== ".." && part !== "." && part !== ""));
14
35
  export const localStartRequestSchema = z.strictObject({
@@ -39,32 +60,35 @@ export const consoleStatusSchema = z.strictObject({
39
60
  settlement: z.enum(["unheld", "held", "charged", "released"]),
40
61
  reason: z.enum(["service_failure", "required_failure", "authorization_refused", "our_deadline_failure",
41
62
  "findings_saved", "required_delivered", "no_required_delivery"]).nullable(),
42
- uploadState: z.enum(["saved", "missing"]), analysisId: uuid.optional(), findingIds: z.array(uuid).max(20_000).optional(),
63
+ uploadState: z.enum(["saved", "missing"]), analysisId: uuid.optional(), findingIds: z.array(uuid).max(RESULT_CHECK_LIMITS.findings).optional(),
64
+ findings: numberedFindingsSchema.optional(),
43
65
  quoteId: z.string().min(1).max(256).optional(), priceMicro: money.nullable(), providerBudgetMicro: money,
44
66
  expiresAt: z.iso.datetime(), absoluteExpiresAt: z.iso.datetime().nullable(),
45
67
  deadlineClassification: z.enum(["caller_timeout", "our_deadline_failure"]).nullable(),
46
68
  }).refine(value => value.uploadState === "saved"
47
69
  ? value.analysisId !== undefined && value.findingIds !== undefined
48
- : value.analysisId === undefined && value.findingIds === undefined);
70
+ : value.analysisId === undefined && value.findingIds === undefined && value.findings === undefined)
71
+ .refine(matchingFindingNumbers);
49
72
  export const consoleReservationSchema = consoleStatusSchema.safeExtend({ reservationToken: z.string().min(1).max(256),
50
73
  quoteId: z.string().min(1).max(256) });
74
+ /**
75
+ * One findingId per payload finding, in payload order; optional findings maps those ids to project sequences in any order.
76
+ * Console must send findings in intake replies and saved status (including seq in status) only when
77
+ * x-counterfact-cli-version is 0.2.5 or later: older CLIs use strict schemas and reject these fields after a paid save.
78
+ */
51
79
  export const consoleIntakeResultSchema = z.strictObject({
52
- checkId: uuid, projectId: uuid, analysisId: uuid, findingIds: z.array(uuid).max(20_000),
80
+ checkId: uuid, projectId: uuid, analysisId: uuid, findingIds: z.array(uuid).max(RESULT_CHECK_LIMITS.findings),
81
+ findings: numberedFindingsSchema.optional(),
53
82
  settlement: z.enum(["unheld", "held", "charged", "released"]).optional(),
54
- });
83
+ }).refine(matchingFindingNumbers);
55
84
  export const LOCAL_FINDINGS_BODY_LIMIT = 2 * 1024 * 1024;
56
- export const LOCAL_FINDINGS_QUOTE_LIMIT = 2000;
57
- export const LOCAL_FINDINGS_TEXT_LIMIT = 4096;
85
+ export const LOCAL_FINDINGS_QUOTE_LIMIT = RESULT_CHECK_LIMITS.quoteBytes;
86
+ export const LOCAL_FINDINGS_TEXT_LIMIT = RESULT_CHECK_LIMITS.textBytes;
58
87
  function shortFindingFields(value) {
59
88
  if (typeof value === "string")
60
89
  return Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_TEXT_LIMIT;
61
90
  return value === null || typeof value !== "object" || Object.values(value).every(shortFindingFields);
62
91
  }
63
- function validIntakeText(value) {
64
- if (typeof value === "string")
65
- return !value.includes(String.fromCharCode(0)) && !/[\uD800-\uDFFF]/u.test(value);
66
- return value === null || typeof value !== "object" || Object.values(value).every(validIntakeText);
67
- }
68
92
  const quote = z.string().refine(value => Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_QUOTE_LIMIT);
69
93
  const text = z.string().min(1).refine(value => value.trim().length > 0);
70
94
  const delta = fixDeltaSchema.extend({
@@ -80,7 +104,8 @@ const notes = sourceNotesSchema.extend({
80
104
  sites: z.array(sourceNotesSchema.shape.sites.element.extend({ keyPhrase: quote }).strict()),
81
105
  counterEvidence: z.array(sourceNotesSchema.shape.counterEvidence.element.extend({ excerpt: quote }).strict()),
82
106
  }).strict();
83
- const finding = rawFindingSchema.extend({
107
+ export const localFindingSchema = rawFindingSchema.extend({
108
+ audience: findingAudienceSchema.optional(),
84
109
  summary: z.string().nullable(),
85
110
  category: rawFindingSchema.shape.category.removeCatch(),
86
111
  question: rawFindingSchema.shape.question.removeCatch().nullable(),
@@ -94,10 +119,10 @@ const finding = rawFindingSchema.extend({
94
119
  const payload = z.strictObject({
95
120
  checkId: z.uuid(), projectId: z.uuid(), reservationId: uuid.optional(),
96
121
  agent: z.strictObject({
97
- repository: text.max(4096), commit: z.string().regex(/^(?:[a-f\d]{40}|[a-f\d]{64})$/i),
122
+ repository: text.max(4096), commit: z.string().regex(/^(?:[a-f\d]{40}|[a-f\d]{64})$/i).nullable(),
98
123
  files: z.array(z.strictObject({ path: text.max(4096), hash: z.string().regex(/^[a-f\d]{64}$/i) })).min(1),
99
124
  }),
100
- findings: z.array(finding), bundleVersion: text.max(128), relayVersion: text.max(128), counterfactLocalVersion: text.max(128),
125
+ findings: z.array(localFindingSchema), bundleVersion: text.max(128), relayVersion: text.max(128), counterfactLocalVersion: text.max(128),
101
126
  }).refine(validIntakeText);
102
127
  // Original JSON size is checked before a shared schema can trim text or apply defaults.
103
128
  export const localFindingsPayloadSchema = z.unknown().refine(value => {