@counterfact-labs/cli 0.2.4 → 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 (146) 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 +25 -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 +2 -5
  14. package/dist/adapters/release.js +2 -1
  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 +26 -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/{D6AWHLRI.js → 3JWEFCTJ.js} +1 -1
  25. package/dist/analysis-core/chunks/{3227KXHF.js → 4H23CSUM.js} +1 -1
  26. package/dist/analysis-core/chunks/5DGPLXYB.js +1 -0
  27. package/dist/analysis-core/chunks/5TATKWLG.js +1 -0
  28. package/dist/analysis-core/chunks/{EH3QQ3T4.js → 6B2HILSY.js} +1 -1
  29. package/dist/analysis-core/chunks/6QY5BV5B.js +14 -0
  30. package/dist/analysis-core/chunks/6U2IR4XB.js +1 -0
  31. package/dist/analysis-core/chunks/A7WJJMRY.js +1 -0
  32. package/dist/analysis-core/chunks/C6X5GDG4.js +1 -0
  33. package/dist/analysis-core/chunks/DLPVYWHZ.js +1 -0
  34. package/dist/analysis-core/chunks/DZZBQDGO.js +1 -0
  35. package/dist/analysis-core/chunks/F3R4J3ND.js +1 -0
  36. package/dist/analysis-core/chunks/FOKDE64Y.js +1 -0
  37. package/dist/analysis-core/chunks/K2GNDX3W.js +1 -0
  38. package/dist/analysis-core/chunks/KTNA2KTN.js +7 -0
  39. package/dist/analysis-core/chunks/KWECQJ6X.js +1 -0
  40. package/dist/analysis-core/chunks/{SL27GMEN.js → M6HYSTQD.js} +1 -1
  41. package/dist/analysis-core/chunks/{ZAA44GPI.js → N7U5N42V.js} +1 -1
  42. package/dist/analysis-core/chunks/NIOS7U6C.js +41 -0
  43. package/dist/analysis-core/chunks/{QMIXCT62.js → OPMA32A6.js} +1 -1
  44. package/dist/analysis-core/chunks/PECX3OXB.js +1 -0
  45. package/dist/analysis-core/chunks/QFY6A3MS.js +66 -0
  46. package/dist/analysis-core/chunks/SKVEBOGA.js +1 -0
  47. package/dist/analysis-core/chunks/{T66VDFXF.js → STI4JE7L.js} +1 -1
  48. package/dist/analysis-core/chunks/SVMEWGD3.js +1 -0
  49. package/dist/analysis-core/chunks/X7WZAA7S.js +1 -0
  50. package/dist/analysis-core/chunks/YDBO7P4X.js +1 -0
  51. package/dist/analysis-core/chunks/YUY436SM.js +1 -0
  52. package/dist/analysis-core/chunks/ZTOVBAG7.js +1 -0
  53. package/dist/analysis-core/contracts/file-safety.js +1 -0
  54. package/dist/analysis-core/contracts/relay-analysis.js +1 -0
  55. package/dist/analysis-core/contracts/requests.js +1 -1
  56. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  57. package/dist/analysis-core/conventions/file-admission.js +1 -1
  58. package/dist/analysis-core/data/finding-types.js +1 -1
  59. package/dist/analysis-core/findings/sites.js +1 -0
  60. package/dist/analysis-core/github/repo-key.js +1 -1
  61. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  62. package/dist/analysis-core/index.js +3 -3
  63. package/dist/analysis-core/llm/output-limit.js +1 -1
  64. package/dist/analysis-core/llm/types.js +1 -0
  65. package/dist/analysis-core/requests/answer-formats.js +1 -1
  66. package/dist/analysis-core/requests/headline-request.js +1 -1
  67. package/dist/analysis-core/requests/local-plan.js +1 -1
  68. package/dist/analysis-core/requests/registry.js +1 -1
  69. package/dist/analysis-core/requests/template-version.js +1 -1
  70. package/dist/analysis-core/requests/tools.js +1 -1
  71. package/dist/analysis-core/settings.js +1 -1
  72. package/dist/analysis-core/stages/engine-entry.js +1 -0
  73. package/dist/analysis-core/stages/execution-ports.js +1 -0
  74. package/dist/bundle/seal/deserialise.js +9 -1
  75. package/dist/bundle/seal/engine.js +76 -0
  76. package/dist/bundle/seal/freeze.js +9 -0
  77. package/dist/bundle/seal/metadata.js +3 -0
  78. package/dist/bundle/seal/open-engine.js +26 -0
  79. package/dist/bundle/seal/open.js +2 -8
  80. package/dist/cli/commands/analyze.js +36 -4
  81. package/dist/cli/commands/finding.js +9 -3
  82. package/dist/cli/context.js +4 -1
  83. package/dist/cli/finding-display.js +39 -0
  84. package/dist/cli/finding-lookup.js +92 -0
  85. package/dist/cli/finding-settings.js +5 -0
  86. package/dist/cli/fix-requests.js +42 -0
  87. package/dist/cli/intake-payload.js +19 -11
  88. package/dist/cli/main.js +9 -4
  89. package/dist/cli/mcp.js +65 -18
  90. package/dist/cli/relay-check.js +46 -13
  91. package/dist/cli/relay-preparation.js +28 -5
  92. package/dist/cli/result-check.js +68 -0
  93. package/dist/cli/summary.js +10 -3
  94. package/dist/contract/agent-choice.js +15 -0
  95. package/dist/contract/agent-proposal.js +38 -0
  96. package/dist/contract/attestation.js +106 -0
  97. package/dist/contract/cli-refusals.js +3 -0
  98. package/dist/contract/cli-version.js +1 -1
  99. package/dist/contract/console.js +21 -16
  100. package/dist/contract/engine.js +44 -0
  101. package/dist/contract/findings.js +28 -0
  102. package/dist/contract/fix-requests.js +30 -0
  103. package/dist/contract/messages.js +6 -94
  104. package/dist/contract/public-report.js +1 -0
  105. package/dist/contract/runtime-environment.js +12 -1
  106. package/dist/core/analysis/run-to-end.js +7 -52
  107. package/dist/gatekeeper/attestation.js +1 -1
  108. package/dist/gatekeeper/channel-binding.js +15 -0
  109. package/dist/gatekeeper/cli-api.js +12 -2
  110. package/dist/gatekeeper/connection-exchange.js +16 -12
  111. package/dist/gatekeeper/model.js +2 -1
  112. package/dist/gatekeeper/production-attestation.js +23 -17
  113. package/dist/gatekeeper/trust/policy.js +4 -1
  114. package/dist/gatekeeper/trust/schema.js +4 -0
  115. package/dist/transport/kms.js +25 -0
  116. package/package.json +4 -3
  117. package/dist/adapters/quality.js +0 -6
  118. package/dist/adapters/report.js +0 -29
  119. package/dist/analysis-core/analysis/analysis-flags.js +0 -1
  120. package/dist/analysis-core/analysis/finding-quality/config.js +0 -1
  121. package/dist/analysis-core/analysis/finding-quality/policy.js +0 -1
  122. package/dist/analysis-core/analysis/headlines/attachment.js +0 -1
  123. package/dist/analysis-core/analysis/headlines/input.js +0 -1
  124. package/dist/analysis-core/chunks/3CKW5JIF.js +0 -1
  125. package/dist/analysis-core/chunks/6YCABS5T.js +0 -1
  126. package/dist/analysis-core/chunks/7OYIBWX3.js +0 -1
  127. package/dist/analysis-core/chunks/DRGDIIFA.js +0 -1
  128. package/dist/analysis-core/chunks/FTODBFQW.js +0 -1
  129. package/dist/analysis-core/chunks/HPIAEQHE.js +0 -37
  130. package/dist/analysis-core/chunks/IVDDV6CU.js +0 -14
  131. package/dist/analysis-core/chunks/JF4GYMOZ.js +0 -1
  132. package/dist/analysis-core/chunks/K5OTWBTT.js +0 -22
  133. package/dist/analysis-core/chunks/MXXNED6N.js +0 -7
  134. package/dist/analysis-core/chunks/NCWEOJVJ.js +0 -6
  135. package/dist/analysis-core/chunks/O652FQQH.js +0 -1
  136. package/dist/analysis-core/chunks/OFBJNHC6.js +0 -1
  137. package/dist/analysis-core/chunks/QV2EOS7U.js +0 -1
  138. package/dist/analysis-core/chunks/STZOC7LN.js +0 -1
  139. package/dist/analysis-core/chunks/T2SAP7OV.js +0 -60
  140. package/dist/analysis-core/chunks/T5TREZCQ.js +0 -1
  141. package/dist/analysis-core/chunks/TDNTU4TF.js +0 -1
  142. package/dist/analysis-core/chunks/U5HOEVPE.js +0 -1
  143. package/dist/analysis-core/chunks/UEWL3GMQ.js +0 -1
  144. package/dist/analysis-core/chunks/V7BFUTM7.js +0 -1
  145. package/dist/core/analysis/finding-quality/config.js +0 -4
  146. package/dist/core/analysis/structural-run.js +0 -16
@@ -1,30 +1,53 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { join, relative, sep } from "node:path";
2
3
  import { outsideLoadersEnabled } from "../analysis-core/settings.js";
4
+ import { resolveFolderPath } from "../adapters/folder-path.js";
3
5
  import { readLocalFolder } from "../adapters/files.js";
4
- import { snapshotProvenance } from "../adapters/snapshot.js";
6
+ import { snapshotProvenance, sameFileHashes } from "../adapters/snapshot.js";
5
7
  import { validateClientRelease } from "../adapters/release.js";
6
8
  import { prepareRelayPlan } from "../adapters/relay-plan.js";
9
+ import { AgentChoiceError } from "../contract/agent-choice.js";
7
10
  import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
11
+ import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
8
12
  import { repositoryFacts } from "./git.js";
9
13
  /** Absolute checkout paths stay local; repository facts own the start's commit. */
10
14
  export function buildRelayStartPayload(facts, files) {
11
15
  const { remoteUrl, relPath, folderName, branch, commit, dirty } = facts;
12
16
  return { execution: "local", remoteUrl, relPath, folderName, branch, commit, dirty, files };
13
17
  }
18
+ export function committedFilesOnly(settings) {
19
+ return settings.CF_LOCAL_COMMITTED_ONLY === "1";
20
+ }
14
21
  /** Recovery can reuse signature validation without reading the folder again. */
15
22
  export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
16
- const { bundle, tools } = validated;
23
+ const { bundle, tools, settings } = validated;
17
24
  options.signal?.throwIfAborted();
18
25
  // Discovery must agree with Git before publishing repository-relative captures.
19
26
  const earlyFacts = outsideLoadersEnabled(options.settings) ? await (options.facts ?? repositoryFacts)(folder) : undefined;
20
27
  options.signal?.throwIfAborted();
21
28
  const read = await readLocalFolder(options.settings, folder, options.trialAdmission, { captureSnapshot: true,
29
+ proposal: options.proposal,
22
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
+ }
23
42
  const source = { ...read, files: Object.freeze({ ...read.files }) };
24
- const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, source.sourceBase ? { selectionFolder: folder } : undefined);
25
- 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)
26
49
  throw new Error(COMMITTED_FILES_MESSAGE);
27
- 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 });
28
51
  options.signal?.throwIfAborted();
29
52
  const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(folder);
30
53
  options.signal?.throwIfAborted();
@@ -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();
@@ -5,6 +5,8 @@ export const FINISH_FAILURE_MESSAGE = "The analysis couldn't finish. Try again."
5
5
  export const RESULTS_TOO_LARGE_MESSAGE = "The results were too large to save. Try again.";
6
6
  export const COMMITTED_FILES_MESSAGE = "Counterfact analyzes the committed version of the agent's files. Commit your changes, then try again.";
7
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.";
8
10
  /** Shared public refusals keep mode admission and reservation errors consistent. */
9
11
  export const cliRefusals = {
10
12
  folder_too_large: FOLDER_TOO_LARGE_MESSAGE,
@@ -12,6 +14,7 @@ export const cliRefusals = {
12
14
  local_checks_paused: "Analyses are paused right now. Try again later.",
13
15
  cli_update_required: "Update Counterfact: run npm install -g @counterfact-labs/cli@latest.",
14
16
  release_unavailable: unsupportedVersion,
17
+ plan_over_limit: analysisTooLarge,
15
18
  invalid_plan: unsupportedVersion,
16
19
  invalid_body: "Counterfact couldn't read this request. Update Counterfact, then try again.",
17
20
  body_too_large: "This request is too large. The limit is 2 MB.",
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.2.4";
1
+ export const CLI_VERSION = "0.2.5";
@@ -1,10 +1,11 @@
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
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";
6
8
  export const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/);
7
- const uuid = z.uuid().transform(value => value.toLowerCase());
8
9
  const money = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
9
10
  const count = (max) => z.number().int().positive().max(max);
10
11
  // These data-only boundaries follow the SaaS local start, reservation and findings owners; see docs/provenance.md.
@@ -59,32 +60,35 @@ export const consoleStatusSchema = z.strictObject({
59
60
  settlement: z.enum(["unheld", "held", "charged", "released"]),
60
61
  reason: z.enum(["service_failure", "required_failure", "authorization_refused", "our_deadline_failure",
61
62
  "findings_saved", "required_delivered", "no_required_delivery"]).nullable(),
62
- 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(),
63
65
  quoteId: z.string().min(1).max(256).optional(), priceMicro: money.nullable(), providerBudgetMicro: money,
64
66
  expiresAt: z.iso.datetime(), absoluteExpiresAt: z.iso.datetime().nullable(),
65
67
  deadlineClassification: z.enum(["caller_timeout", "our_deadline_failure"]).nullable(),
66
68
  }).refine(value => value.uploadState === "saved"
67
69
  ? value.analysisId !== undefined && value.findingIds !== undefined
68
- : value.analysisId === undefined && value.findingIds === undefined);
70
+ : value.analysisId === undefined && value.findingIds === undefined && value.findings === undefined)
71
+ .refine(matchingFindingNumbers);
69
72
  export const consoleReservationSchema = consoleStatusSchema.safeExtend({ reservationToken: z.string().min(1).max(256),
70
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
+ */
71
79
  export const consoleIntakeResultSchema = z.strictObject({
72
- 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(),
73
82
  settlement: z.enum(["unheld", "held", "charged", "released"]).optional(),
74
- });
83
+ }).refine(matchingFindingNumbers);
75
84
  export const LOCAL_FINDINGS_BODY_LIMIT = 2 * 1024 * 1024;
76
- export const LOCAL_FINDINGS_QUOTE_LIMIT = 2000;
77
- 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;
78
87
  function shortFindingFields(value) {
79
88
  if (typeof value === "string")
80
89
  return Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_TEXT_LIMIT;
81
90
  return value === null || typeof value !== "object" || Object.values(value).every(shortFindingFields);
82
91
  }
83
- function validIntakeText(value) {
84
- if (typeof value === "string")
85
- return !value.includes(String.fromCharCode(0)) && !/[\uD800-\uDFFF]/u.test(value);
86
- return value === null || typeof value !== "object" || Object.values(value).every(validIntakeText);
87
- }
88
92
  const quote = z.string().refine(value => Buffer.byteLength(value, "utf8") <= LOCAL_FINDINGS_QUOTE_LIMIT);
89
93
  const text = z.string().min(1).refine(value => value.trim().length > 0);
90
94
  const delta = fixDeltaSchema.extend({
@@ -100,7 +104,8 @@ const notes = sourceNotesSchema.extend({
100
104
  sites: z.array(sourceNotesSchema.shape.sites.element.extend({ keyPhrase: quote }).strict()),
101
105
  counterEvidence: z.array(sourceNotesSchema.shape.counterEvidence.element.extend({ excerpt: quote }).strict()),
102
106
  }).strict();
103
- const finding = rawFindingSchema.extend({
107
+ export const localFindingSchema = rawFindingSchema.extend({
108
+ audience: findingAudienceSchema.optional(),
104
109
  summary: z.string().nullable(),
105
110
  category: rawFindingSchema.shape.category.removeCatch(),
106
111
  question: rawFindingSchema.shape.question.removeCatch().nullable(),
@@ -114,10 +119,10 @@ const finding = rawFindingSchema.extend({
114
119
  const payload = z.strictObject({
115
120
  checkId: z.uuid(), projectId: z.uuid(), reservationId: uuid.optional(),
116
121
  agent: z.strictObject({
117
- 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(),
118
123
  files: z.array(z.strictObject({ path: text.max(4096), hash: z.string().regex(/^[a-f\d]{64}$/i) })).min(1),
119
124
  }),
120
- 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),
121
126
  }).refine(validIntakeText);
122
127
  // Original JSON size is checked before a shared schema can trim text or apply defaults.
123
128
  export const localFindingsPayloadSchema = z.unknown().refine(value => {
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { ANALYSIS_PROTOCOL, analysisCapabilities } from "../analysis-core/contracts/relay-analysis.js";
3
+ import { stableStringify } from "../analysis-core/llm/stable-json.js";
4
+ import { workloadHardware } from "../gatekeeper/trust/schema.js";
5
+ import { kmsKeyName } from "../transport/kms.js";
6
+ export const ENGINE_ABI = 1;
7
+ export const ENGINE_SPAWNER_CONTRACT = 1;
8
+ const hash = z.string().regex(/^[0-9a-f]{64}$/);
9
+ const version = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
10
+ export const engineIdentity = z.strictObject({
11
+ abi: z.literal(ENGINE_ABI), protocol: z.literal(ANALYSIS_PROTOCOL), spawnerContract: z.literal(ENGINE_SPAWNER_CONTRACT),
12
+ engineVersion: version, coreVersion: version, logicCommit: z.string().regex(/^[0-9a-f]{40}$/),
13
+ programSha256: hash, closureSha256: hash,
14
+ });
15
+ export const engineKeyPolicy = z.strictObject({
16
+ wrappingKey: z.string().refine(value => { try {
17
+ kmsKeyName(value);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ } }),
23
+ workloadPolicySha256: hash, hardware: workloadHardware,
24
+ });
25
+ export const enginePin = engineIdentity.extend({ artifactSha256: hash, keyPolicy: engineKeyPolicy }).strict();
26
+ export const analysisRelease = z.strictObject({ engine: enginePin, wire: analysisCapabilities }).refine(value => value.wire.protocol === value.engine.protocol && value.wire.engineVersion === value.engine.engineVersion);
27
+ export function matchEngineKeyPolicy(keyPolicy, release) {
28
+ const policy = engineKeyPolicy.parse(keyPolicy);
29
+ if (release.bundleKey !== undefined && policy.wrappingKey !== kmsKeyName(release.bundleKey) ||
30
+ release.workloadPolicySha256 !== undefined && policy.workloadPolicySha256 !== release.workloadPolicySha256 ||
31
+ release.workloadPolicy !== undefined && !release.workloadPolicy.hardwareModels.includes(policy.hardware))
32
+ throw new Error("Invalid engine key policy");
33
+ }
34
+ export function samePin(left, right) {
35
+ if (!left || !right)
36
+ return left === right;
37
+ return stableStringify(enginePin.parse(left)) === stableStringify(enginePin.parse(right));
38
+ }
39
+ /** Historical releases carry no engine. New analysis evidence must be complete and agree at every boundary. */
40
+ export function matchAnalysisRelease(value, pin, bundleVersion) {
41
+ const parsed = analysisRelease.parse(value);
42
+ if (!samePin(parsed.engine, pin) || parsed.wire.bundleVersion !== bundleVersion)
43
+ throw new Error("Analysis release differs");
44
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ export const findingSequenceSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
3
+ export const canonicalUuidSchema = z.uuid().transform(value => value.toLowerCase());
4
+ export const numberedFindingSchema = z.strictObject({
5
+ id: canonicalUuidSchema, seq: findingSequenceSchema,
6
+ });
7
+ export const numberedFindingsSchema = z.array(numberedFindingSchema).max(20_000)
8
+ .refine(values => new Set(values.map(value => value.id)).size === values.length
9
+ && new Set(values.map(value => value.seq)).size === values.length);
10
+ /** Both intake and recovery status must refer only to their committed finding ids. */
11
+ export function matchingFindingNumbers(value) {
12
+ if (value.findings === undefined)
13
+ return true;
14
+ const ids = new Set(value.findingIds);
15
+ return value.findings.every(finding => ids.has(finding.id));
16
+ }
17
+ /**
18
+ * Console route to implement: GET /api/cli/projects/{projectId}/findings/{seq}.
19
+ * Requires the signed-in bearer token and project access; no body and no charge.
20
+ * Params: { projectId: UUID, seq: positive decimal integer }. 200: { id: UUID, seq: number }.
21
+ * 404 means that finding does not exist in this project. The caller then loads
22
+ * the explanation from the existing GET /api/cli/findings/{id} route, whose
23
+ * response may add seq: number. Numbers are never resolved across projects.
24
+ */
25
+ export const findingNumberParamsSchema = z.strictObject({
26
+ projectId: canonicalUuidSchema, seq: z.string().regex(/^[1-9][0-9]*$/).transform(Number).pipe(findingSequenceSchema),
27
+ });
28
+ export const findingNumberResponseSchema = numberedFindingSchema;
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { canonicalUuidSchema, findingSequenceSchema } from "./findings.js";
3
+ /**
4
+ * Console routes to implement; both require the signed-in CLI bearer token.
5
+ * GET /api/cli/fix-requests has no body and returns 200 with a JSON array:
6
+ * [{ findingId: UUID, seq: positive integer, title: string, projectId: UUID,
7
+ * requestedAt: ISO-8601 UTC timestamp, repoKey?: string }]. Empty is [].
8
+ * repoKey is the finding's console repository key, including its scoped folder
9
+ * when present (for example fictional/support/agents/fern or local:fern).
10
+ * Older consoles may omit it. Only this user's
11
+ * unclaimed requests for accessible findings are returned. No charge or writes.
12
+ *
13
+ * POST /api/cli/fix-requests/{id}/claim takes JSON {} and returns 200 with
14
+ * { findingId: UUID, claimed: true }. The path id is the findingId from the list;
15
+ * there is one waiting request per user and finding, so no separate request id.
16
+ * Claiming is idempotent for that user, removes it from their waiting list and
17
+ * never starts analysis, applies edits or charges. Re-requesting a finding makes
18
+ * it waiting again. A missing or inaccessible request returns 404; unauthenticated
19
+ * requests return 401. The console owns authorization and atomic claiming.
20
+ */
21
+ export const waitingFixSchema = z.object({
22
+ findingId: canonicalUuidSchema, seq: findingSequenceSchema, title: z.string().min(1).max(4096),
23
+ projectId: canonicalUuidSchema, requestedAt: z.iso.datetime(),
24
+ repoKey: z.string().min(1).max(4096).refine(value => value.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(value)).optional(),
25
+ });
26
+ export const waitingFixesResponseSchema = z.array(waitingFixSchema).max(20_000)
27
+ .refine(values => new Set(values.map(value => value.findingId)).size === values.length);
28
+ export const claimFixRequestParamsSchema = z.strictObject({ id: canonicalUuidSchema });
29
+ export const claimFixRequestBodySchema = z.strictObject({});
30
+ export const claimFixRequestResponseSchema = z.strictObject({ findingId: canonicalUuidSchema, claimed: z.literal(true) });