@counterfact-labs/cli 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +178 -38
  2. package/bundle/client-release.json +7 -0
  3. package/bundle/sealed.json +908 -0
  4. package/dist/adapters/anthropic.js +151 -0
  5. package/dist/adapters/completion.js +112 -0
  6. package/dist/adapters/durable-write.js +34 -0
  7. package/dist/adapters/files.js +140 -0
  8. package/dist/adapters/folder-path.js +6 -0
  9. package/dist/adapters/git-ignore.js +49 -0
  10. package/dist/adapters/git.js +44 -0
  11. package/dist/adapters/ignored-files.js +36 -0
  12. package/dist/adapters/instructions.js +35 -0
  13. package/dist/adapters/models.js +51 -0
  14. package/dist/adapters/output-paths.js +92 -0
  15. package/dist/adapters/prompt-cache.js +28 -0
  16. package/dist/adapters/public-instructions.js +36 -0
  17. package/dist/adapters/quality.js +6 -0
  18. package/dist/adapters/relay-plan.js +15 -0
  19. package/dist/adapters/release.js +57 -0
  20. package/dist/adapters/report.js +29 -0
  21. package/dist/adapters/repository-files.js +199 -0
  22. package/dist/adapters/repository-root.js +120 -0
  23. package/dist/adapters/results.js +9 -0
  24. package/dist/adapters/settings.js +25 -0
  25. package/dist/adapters/snapshot.js +68 -0
  26. package/dist/analysis-core/analysis/analysis-flags.js +1 -0
  27. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
  28. package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
  29. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  30. package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
  31. package/dist/analysis-core/analysis/headlines/input.js +1 -0
  32. package/dist/analysis-core/analysis/headlines/types.js +1 -0
  33. package/dist/analysis-core/analysis/schemas.js +1 -0
  34. package/dist/analysis-core/analysis/source-notes/types.js +1 -0
  35. package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
  36. package/dist/analysis-core/chunks/3227KXHF.js +1 -0
  37. package/dist/analysis-core/chunks/326F4XNG.js +1 -0
  38. package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
  39. package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
  40. package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
  41. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  42. package/dist/analysis-core/chunks/724WZMYW.js +2 -0
  43. package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
  44. package/dist/analysis-core/chunks/7QDOHWSY.js +27 -0
  45. package/dist/analysis-core/chunks/BZNHHLNP.js +1 -0
  46. package/dist/analysis-core/chunks/DTMHXKP6.js +7 -0
  47. package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
  48. package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
  49. package/dist/analysis-core/chunks/EQTIRIMU.js +37 -0
  50. package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
  51. package/dist/analysis-core/chunks/G322LST6.js +1 -0
  52. package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
  53. package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
  54. package/dist/analysis-core/chunks/IL2KRP3T.js +1 -0
  55. package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
  56. package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
  57. package/dist/analysis-core/chunks/KEKTZDIK.js +1 -0
  58. package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
  59. package/dist/analysis-core/chunks/M7MMAUAK.js +1 -0
  60. package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
  61. package/dist/analysis-core/chunks/ORCYIIHB.js +60 -0
  62. package/dist/analysis-core/chunks/PVPEY7NF.js +1 -0
  63. package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
  64. package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
  65. package/dist/analysis-core/chunks/QPXNXMLC.js +1 -0
  66. package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
  67. package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
  68. package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
  69. package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
  70. package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
  71. package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
  72. package/dist/analysis-core/chunks/VER355VN.js +1 -0
  73. package/dist/analysis-core/chunks/VFENG4MR.js +1 -0
  74. package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
  75. package/dist/analysis-core/chunks/Y5AUKKYG.js +1 -0
  76. package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
  77. package/dist/analysis-core/chunks/YVDCJN7D.js +14 -0
  78. package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
  79. package/dist/analysis-core/chunks/ZMUBMYWN.js +1 -0
  80. package/dist/analysis-core/contracts/requests.js +1 -0
  81. package/dist/analysis-core/conventions/file-admission.js +1 -0
  82. package/dist/analysis-core/data/finding-types.js +1 -0
  83. package/dist/analysis-core/errors.js +1 -0
  84. package/dist/analysis-core/findings/presentation.js +1 -0
  85. package/dist/analysis-core/github/repo-key.js +1 -0
  86. package/dist/analysis-core/import/outside-loader-sources.js +1 -0
  87. package/dist/analysis-core/index.js +6 -0
  88. package/dist/analysis-core/llm/cache-policy.js +1 -0
  89. package/dist/analysis-core/llm/cost.js +1 -0
  90. package/dist/analysis-core/llm/execution.js +1 -0
  91. package/dist/analysis-core/llm/output-limit.js +1 -0
  92. package/dist/analysis-core/llm/slots.js +1 -0
  93. package/dist/analysis-core/llm/stable-json.js +1 -0
  94. package/dist/analysis-core/llm/structured-output-error.js +1 -0
  95. package/dist/analysis-core/map-limit.js +1 -0
  96. package/dist/analysis-core/requests/answer-formats.js +1 -0
  97. package/dist/analysis-core/requests/check-diagnostics.js +1 -0
  98. package/dist/analysis-core/requests/headline-request.js +1 -0
  99. package/dist/analysis-core/requests/instruction-text.js +1 -0
  100. package/dist/analysis-core/requests/local-plan.js +1 -0
  101. package/dist/analysis-core/requests/registry.js +1 -0
  102. package/dist/analysis-core/requests/schema-descriptions.js +1 -0
  103. package/dist/analysis-core/requests/shape-finding-input.js +1 -0
  104. package/dist/analysis-core/requests/template-version.js +1 -0
  105. package/dist/analysis-core/requests/tools.js +1 -0
  106. package/dist/analysis-core/settings.js +1 -0
  107. package/dist/bundle/seal/deserialise.js +75 -0
  108. package/dist/bundle/seal/encryption.js +25 -0
  109. package/dist/bundle/seal/metadata.js +69 -0
  110. package/dist/bundle/seal/open.js +38 -0
  111. package/dist/bundle/seal/planned-calls.js +20 -0
  112. package/dist/bundle/seal/signature.js +51 -0
  113. package/dist/cli/agent-folder.js +17 -0
  114. package/dist/cli/api-types.js +2 -0
  115. package/dist/cli/api.js +1 -0
  116. package/dist/cli/browser.js +23 -0
  117. package/dist/cli/claude-code-permission.js +20 -0
  118. package/dist/cli/commands/analyze.js +73 -0
  119. package/dist/cli/commands/login.js +51 -0
  120. package/dist/cli/completion-recovery.js +18 -0
  121. package/dist/cli/context.js +22 -0
  122. package/dist/cli/email-sign-in.js +56 -0
  123. package/dist/cli/git.js +39 -0
  124. package/dist/cli/intake-payload.js +18 -0
  125. package/dist/{main.js → cli/main.js} +23 -12
  126. package/dist/cli/mcp.js +297 -0
  127. package/dist/cli/output.js +10 -0
  128. package/dist/cli/relay-check.js +239 -0
  129. package/dist/cli/relay-preparation.js +31 -0
  130. package/dist/cli/sign-in-input.js +97 -0
  131. package/dist/cli/sign-in.js +37 -0
  132. package/dist/cli/suggested-email.js +10 -0
  133. package/dist/cli/summary.js +30 -0
  134. package/dist/contract/cli-api-messages.js +5 -0
  135. package/dist/contract/cli-refusals.js +22 -0
  136. package/dist/contract/cli-version.js +1 -0
  137. package/dist/contract/console-url.js +8 -0
  138. package/dist/contract/console.js +111 -0
  139. package/dist/contract/email-sign-in-messages.js +18 -0
  140. package/dist/contract/messages.js +252 -0
  141. package/dist/contract/release-key.js +2 -0
  142. package/dist/contract/runtime-environment.js +37 -0
  143. package/dist/core/analysis/finding-quality/config.js +4 -0
  144. package/dist/core/analysis/run-to-end.js +55 -0
  145. package/dist/core/analysis/structural-run.js +16 -0
  146. package/dist/gatekeeper/attestation.js +67 -0
  147. package/dist/gatekeeper/certificate-algorithms.js +18 -0
  148. package/dist/gatekeeper/certificate-path.js +99 -0
  149. package/dist/gatekeeper/cli-api.js +174 -0
  150. package/dist/gatekeeper/client.js +76 -0
  151. package/dist/gatekeeper/connection-exchange.js +60 -0
  152. package/dist/gatekeeper/connection.js +140 -0
  153. package/dist/gatekeeper/console.js +168 -0
  154. package/dist/gatekeeper/loopback.js +5 -0
  155. package/dist/gatekeeper/model.js +70 -0
  156. package/dist/gatekeeper/production-attestation.js +149 -0
  157. package/dist/gatekeeper/trust/hardware.js +21 -0
  158. package/dist/gatekeeper/trust/policy.js +42 -0
  159. package/dist/gatekeeper/trust/production.json +25 -0
  160. package/dist/setup-pings/client.js +31 -0
  161. package/dist/setup-pings/contract.js +2 -0
  162. package/dist/setup-pings/policy.js +3 -0
  163. package/dist/setup-pings/state.js +261 -0
  164. package/dist/setup-pings/worker.js +95 -0
  165. package/dist/transport/base64url.js +9 -0
  166. package/dist/transport/http-response.js +89 -0
  167. package/dist/transport/tls-exporter.js +5 -0
  168. package/package.json +57 -11
  169. package/LICENSE +0 -1
  170. package/dist/api-types.js +0 -2
  171. package/dist/api.js +0 -97
  172. package/dist/commands/check.js +0 -104
  173. package/dist/commands/login.js +0 -21
  174. package/dist/context.js +0 -27
  175. package/dist/files.js +0 -99
  176. package/dist/git.js +0 -21
  177. package/dist/gitignore.js +0 -70
  178. package/dist/mcp.js +0 -165
  179. package/dist/output.js +0 -23
  180. package/dist/secret-files.js +0 -5
  181. /package/dist/{commands → cli/commands}/balance.js +0 -0
  182. /package/dist/{commands → cli/commands}/finding.js +0 -0
  183. /package/dist/{commands → cli/commands}/logout.js +0 -0
  184. /package/dist/{credentials.js → cli/credentials.js} +0 -0
@@ -0,0 +1,55 @@
1
+ import { JobError } from "../../analysis-core/errors.js";
2
+ import { readAnalysisFlags } from "../../analysis-core/analysis/analysis-flags.js";
3
+ import { mapStage, inspectStage, sourceNotesStage, headlinesStage, applyAnalysisDelta, buildAnalysisResult } from "../../analysis-core/index.js";
4
+ import { readFindingQualityConfig } from "./finding-quality/config.js";
5
+ import { buildLocalReport } from "../../adapters/report.js";
6
+ import { runStructures } from "./structural-run.js";
7
+ import { analysisExecution } from "../../adapters/models.js";
8
+ /** Keep the hosted stage order while holding source files and results only in memory. */
9
+ export async function runAnalysisToEnd(input, model, settings = {}, options = {}) {
10
+ const execution = await analysisExecution(model, options, settings);
11
+ const { model: llm, requestTools: tools } = execution;
12
+ const flags = readAnalysisFlags(settings);
13
+ const qualityConfig = readFindingQualityConfig(settings, tools);
14
+ if (!Object.keys(input.files).length)
15
+ throw new JobError("empty_folder", "No admitted source files were found.");
16
+ let prepared = { repos: [{ key: input.repoKey, kind: "fs", files: input.files, ...(input.root !== undefined ? { root: input.root } : {}) }],
17
+ warnings: input.warnings ?? [], ...(input.fragment ? { mapped: [input.fragment] } : {}) };
18
+ const ports = { readers: [], model: llm, instructions: execution.instructions, requestTools: async () => tools, context: {},
19
+ progress: { checkpoint: async () => { }, assertHeld: () => { }, elapsedMs: () => 0, log: () => { } } };
20
+ for (;;) {
21
+ const delta = await mapStage({ phase: "mapping", state: prepared, settings, execution: { scopeConcurrency: 4, forLocal: true,
22
+ preparation: { capUsd: 20, confirmPrice: false, qualityConfig } } }, ports);
23
+ prepared = applyAnalysisDelta(prepared, delta);
24
+ if (delta.next !== "mapping")
25
+ break;
26
+ }
27
+ prepared = { ...prepared, analysisFlags: { ...flags, sourceNotes: !options.findingsOnly && flags.sourceNotes },
28
+ findingQuality: { config: qualityConfig }, warnings: [...(prepared.warnings ?? []), ...qualityConfig.diagnostics] };
29
+ let phase = "inspecting";
30
+ ports.progress.checkpoint = async (fields, part) => {
31
+ prepared = { ...prepared, ...fields, ...(part ? { passResults: { ...prepared.passResults, [part.index]: part.result } } : {}) };
32
+ };
33
+ while (phase !== "structuring") {
34
+ const step = phase === "inspecting" ? inspectStage : sourceNotesStage;
35
+ const delta = await step({ phase, state: prepared, settings, execution: { scopeConcurrency: 4, passConcurrency: 4, forLocal: true } }, ports);
36
+ prepared = applyAnalysisDelta(prepared, delta);
37
+ phase = delta.next;
38
+ }
39
+ const output = () => buildAnalysisResult({ phase: "persisting", state: prepared, settings, execution: { scopeConcurrency: 4 } });
40
+ // Structural child references index the local display order, including optional ranking.
41
+ const report = buildLocalReport(input.repoKey, output(), prepared);
42
+ if (!options.findingsOnly) {
43
+ const findingsRaw = prepared.findingsRaw;
44
+ const structural = await runStructures(settings, llm, { repos: prepared.repos,
45
+ fragments: [{ repoKey: input.repoKey, fragment: prepared.mapped[0] }],
46
+ graph: report.graph, findings: report.findings, state: prepared }, { execution });
47
+ // Structural child references keep display order; wording uses the original cleaned order.
48
+ prepared = { ...structural.state, findingsRaw };
49
+ }
50
+ prepared = applyAnalysisDelta(prepared, await headlinesStage({ phase: "persisting", state: prepared, settings,
51
+ execution: { scopeConcurrency: 4, forLocal: true } }, ports));
52
+ const result = output();
53
+ await options.resultSink?.(result);
54
+ return buildLocalReport(input.repoKey, result, prepared);
55
+ }
@@ -0,0 +1,16 @@
1
+ import { structureStage, applyAnalysisDelta } from "../../analysis-core/index.js";
2
+ import { analysisExecution } from "../../adapters/models.js";
3
+ /** The local product supplies its in-memory allowance; the shared stage owns every transition. */
4
+ export async function runStructures(settings, model, input, options = {}) {
5
+ const execution = await analysisExecution(model, options, settings);
6
+ let state = { ...input.state, repos: input.repos, mapped: input.fragments.map(({ fragment }) => fragment),
7
+ findingsRaw: input.findings, warnings: input.state?.warnings ?? [], splitCheckPriced: true, splitFindingsPriced: true };
8
+ const ports = { readers: [], model: execution.model, instructions: execution.instructions, requestTools: async () => execution.requestTools, context: {},
9
+ progress: { checkpoint: async () => { }, assertHeld: () => { }, elapsedMs: () => 0, log: () => { } } };
10
+ for (;;) {
11
+ const delta = await structureStage({ phase: "structuring", state, settings, execution: { scopeConcurrency: 4, forLocal: true } }, ports);
12
+ state = applyAnalysisDelta(state, delta);
13
+ if (delta.next === "persisting")
14
+ return { state, structures: (state.structuresRaw ?? []), warnings: state.warnings ?? [] };
15
+ }
16
+ }
@@ -0,0 +1,67 @@
1
+ import { createHash, verify } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { MAX_ATTESTATION_BYTES, attestationClaims } from "../contract/messages.js";
4
+ import { decodeBase64url } from "../transport/base64url.js";
5
+ export function attestationPolicy(settings, trust) {
6
+ return { ...trust, allowDevelopment: settings.CF_RELAY_DEV === "1" };
7
+ }
8
+ export class AttestationError extends Error {
9
+ name = "AttestationError";
10
+ }
11
+ const tokenHeader = z.object({
12
+ alg: z.literal("EdDSA"), kid: z.string().min(1).optional(), typ: z.literal("JWT").optional(),
13
+ }).strict();
14
+ /** Local signatures never establish a production identity, even if an old RSA issuer policy is supplied. */
15
+ export function verifyAttestation(token, channelPublicKey, policy, now = Math.floor(Date.now() / 1000)) {
16
+ if (!policy.allowDevelopment)
17
+ throw new AttestationError("Relay development issuer is disabled");
18
+ let payload;
19
+ try {
20
+ if (token.length > MAX_ATTESTATION_BYTES)
21
+ throw new Error();
22
+ const parts = token.split(".");
23
+ if (parts.length !== 3)
24
+ throw new Error();
25
+ const [header, body, signature] = parts;
26
+ const parsed = tokenHeader.parse(JSON.parse(decodeBase64url(header).toString("utf8")));
27
+ payload = decodeBase64url(body);
28
+ const issuer = policy.developmentIssuer;
29
+ const key = issuer?.keys.find(item => parsed.kid === undefined || item.kid === parsed.kid)?.publicKey;
30
+ if (!key || key.type !== "public" || key.asymmetricKeyType !== "ed25519" ||
31
+ !verify(null, Buffer.from(`${header}.${body}`), key, decodeBase64url(signature))) {
32
+ throw new AttestationError("Invalid relay attestation signature");
33
+ }
34
+ }
35
+ catch (error) {
36
+ if (error instanceof AttestationError)
37
+ throw error;
38
+ throw new AttestationError("Malformed relay attestation token");
39
+ }
40
+ const result = attestationClaims.safeParse((() => {
41
+ try {
42
+ return JSON.parse(payload.toString("utf8"));
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ })());
48
+ if (!result.success)
49
+ throw new AttestationError("Invalid relay attestation claims");
50
+ const claims = result.data;
51
+ if (claims.iss !== policy.developmentIssuer?.issuer)
52
+ throw new AttestationError("Untrusted relay attestation issuer");
53
+ if (!policy.imageDigests.includes(claims.imageDigest))
54
+ throw new AttestationError("Untrusted relay image digest");
55
+ if (!Number.isSafeInteger(now))
56
+ throw new AttestationError("Invalid attestation clock");
57
+ if (claims.exp <= now)
58
+ throw new AttestationError("Relay attestation has expired");
59
+ if (claims.iat > now || claims.exp <= claims.iat)
60
+ throw new AttestationError("Invalid relay attestation lifetime");
61
+ if (channelPublicKey.type !== "public")
62
+ throw new AttestationError("Invalid relay channel key");
63
+ const hash = createHash("sha256").update(channelPublicKey.export({ format: "der", type: "spki" })).digest("hex");
64
+ if (claims.channelKeyHash !== hash)
65
+ throw new AttestationError("Relay attestation channel key mismatch");
66
+ return claims;
67
+ }
@@ -0,0 +1,18 @@
1
+ export const RSA_PSS_SIGNATURE_ALGORITHM = "1.2.840.113549.1.1.10";
2
+ const curves = new Set(["prime256v1", "secp384r1", "secp521r1"]);
3
+ const signatures = new Set([
4
+ "1.2.840.113549.1.1.11", "1.2.840.113549.1.1.12", "1.2.840.113549.1.1.13",
5
+ "1.2.840.10045.4.3.2", "1.2.840.10045.4.3.3", "1.2.840.10045.4.3.4",
6
+ "1.3.101.112", "1.3.101.113",
7
+ ]);
8
+ const pssHashes = new Set(["2.16.840.1.101.3.4.2.1", "2.16.840.1.101.3.4.2.2", "2.16.840.1.101.3.4.2.3"]);
9
+ /** WebCrypto can verify obsolete algorithms; accepting a signature also requires a local strength policy. */
10
+ export function hasStrongCertificateAlgorithms(algorithms) {
11
+ const { keyType, modulusLength, namedCurve, signatureAlgorithm, pssHashAlgorithm } = algorithms;
12
+ const strongKey = keyType === "rsa" || keyType === "rsa-pss" ? (modulusLength ?? 0) >= 2048
13
+ : keyType === "ec" ? curves.has(namedCurve ?? "") : keyType === "ed25519" || keyType === "ed448";
14
+ if (!strongKey)
15
+ return false;
16
+ return signatureAlgorithm === RSA_PSS_SIGNATURE_ALGORITHM
17
+ ? pssHashes.has(pssHashAlgorithm ?? "") : signatures.has(signatureAlgorithm);
18
+ }
@@ -0,0 +1,99 @@
1
+ import { X509Certificate, webcrypto } from "node:crypto";
2
+ import { BitString, fromBER } from "asn1js";
3
+ import { BasicConstraints, Certificate, CertificateChainValidationEngine, CryptoEngine, ExtensionValueFactory, RSASSAPSSParams } from "pkijs";
4
+ import { AttestationError } from "./attestation.js";
5
+ import { hasStrongCertificateAlgorithms, RSA_PSS_SIGNATURE_ALGORITHM } from "./certificate-algorithms.js";
6
+ const crypto = new CryptoEngine({ name: "node", crypto: webcrypto,
7
+ subtle: webcrypto.subtle });
8
+ function parseCertificate(cert) {
9
+ const bytes = Uint8Array.from(cert.raw);
10
+ const decoded = fromBER(bytes.buffer);
11
+ if (decoded.offset !== bytes.length)
12
+ throw new Error();
13
+ return new Certificate({ schema: decoded.result });
14
+ }
15
+ function checkPathConstraints(path) {
16
+ let intermediateCount = 0;
17
+ for (const [index, cert] of path.entries()) {
18
+ for (const extension of cert.extensions ?? []) {
19
+ if (!extension.critical)
20
+ continue;
21
+ // PKIjs also parses unknown extensions as generic ASN.1; parsing alone does not establish recognition.
22
+ // Subject key identifier, key usage and inhibit-any-policy use primitive ASN.1 rather than registered classes.
23
+ const recognized = ExtensionValueFactory.find(extension.extnID) || ["2.5.29.14", "2.5.29.15", "2.5.29.54"].includes(extension.extnID);
24
+ const value = extension.parsedValue;
25
+ if (!recognized || !value || "parsingError" in value)
26
+ throw new Error();
27
+ }
28
+ // PKIjs does not enforce pathLenConstraint. Count only non-self-issued CAs below each issuer, never the leaf.
29
+ const constraints = cert.extensions?.find(extension => extension.extnID === "2.5.29.19")?.parsedValue;
30
+ if (constraints instanceof BasicConstraints && constraints.pathLenConstraint !== undefined) {
31
+ const limit = constraints.pathLenConstraint;
32
+ if (intermediateCount > (typeof limit === "number" ? limit : limit.toBigInt()))
33
+ throw new Error();
34
+ }
35
+ if (index > 0 && !cert.subject.isEqual(cert.issuer))
36
+ intermediateCount++;
37
+ }
38
+ }
39
+ /** PKIjs owns path validation; only shipped roots are trusted, with no network or system CA discovery. */
40
+ export async function validateCertificatePath(chain, roots, now) {
41
+ try {
42
+ if (!Number.isSafeInteger(now) || chain.length < 1 || chain.length > 5 || !roots.length || roots.length > 8)
43
+ throw new Error();
44
+ // Reviewed root rotations can overlap; an unused expired anchor must not invalidate a different valid path.
45
+ const current = (cert) => now * 1000 >= Date.parse(cert.validFrom) && now * 1000 < Date.parse(cert.validTo);
46
+ const anchors = roots.map(root => new X509Certificate(root)).filter(root => root.ca && current(root));
47
+ if (!anchors.length || chain.some(cert => !current(cert)))
48
+ throw new Error();
49
+ const [leaf] = chain;
50
+ if (leaf.ca || leaf.publicKey.asymmetricKeyType !== "rsa")
51
+ throw new Error();
52
+ // Path builders may discard extraneous certificates; the wire format requires one ordered, connected path.
53
+ if (new Set(chain.map(cert => cert.fingerprint256)).size !== chain.length)
54
+ throw new Error();
55
+ for (let index = 1; index < chain.length; index++) {
56
+ if (!chain[index].ca || !chain[index - 1].checkIssued(chain[index]))
57
+ throw new Error();
58
+ }
59
+ const isAnchor = (cert) => anchors.some(root => root.fingerprint256 === cert.fingerprint256);
60
+ const last = chain.at(-1);
61
+ if (last.checkIssued(last) && !isAnchor(last))
62
+ throw new Error();
63
+ const supplied = chain.map(cert => ({ cert, parsed: parseCertificate(cert) }));
64
+ const trusted = anchors.map(cert => ({ cert, parsed: parseCertificate(cert) }));
65
+ for (const { cert, parsed } of [...supplied, ...trusted]) {
66
+ const key = cert.publicKey;
67
+ const signature = parsed.signatureAlgorithm;
68
+ const pssHashAlgorithm = signature.algorithmId === RSA_PSS_SIGNATURE_ALGORITHM && signature.algorithmParams
69
+ ? new RSASSAPSSParams({ schema: signature.algorithmParams }).hashAlgorithm.algorithmId : undefined;
70
+ if (!hasStrongCertificateAlgorithms({ keyType: key.asymmetricKeyType,
71
+ modulusLength: key.asymmetricKeyDetails?.modulusLength, namedCurve: key.asymmetricKeyDetails?.namedCurve,
72
+ signatureAlgorithm: signature.algorithmId, pssHashAlgorithm }))
73
+ throw new Error();
74
+ }
75
+ // RFC 5280: a leaf without a key usage extension is unrestricted, and Google's Confidential Space leaves omit it.
76
+ // When the extension is present it must still allow digital signatures.
77
+ const usageExtension = supplied[0].parsed.extensions?.find((extension) => extension.extnID === "2.5.29.15");
78
+ if (usageExtension) {
79
+ const usage = usageExtension.parsedValue;
80
+ if (!(usage instanceof BitString) || !(usage.valueBlock.valueHexView[0] & 0x80))
81
+ throw new Error();
82
+ }
83
+ const validator = new CertificateChainValidationEngine({
84
+ trustedCerts: trusted.map(({ parsed }) => parsed),
85
+ // PKIjs takes the target certificate last, independently of the wire's leaf-first order.
86
+ certs: supplied.filter(({ cert }) => !isAnchor(cert)).reverse().map(({ parsed }) => parsed),
87
+ checkDate: new Date(now * 1000),
88
+ });
89
+ const result = await validator.verify({}, crypto);
90
+ if (!result.result || !result.certificatePath)
91
+ throw new Error();
92
+ checkPathConstraints(result.certificatePath);
93
+ return leaf.publicKey;
94
+ }
95
+ catch {
96
+ // Certificate and dependency diagnostics can contain token material; retain only the fixed category.
97
+ throw new AttestationError("Invalid production attestation certificate path");
98
+ }
99
+ }
@@ -0,0 +1,174 @@
1
+ import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
2
+ import { CLI_VERSION } from "../contract/cli-version.js";
3
+ import { consoleSchemeAllowed } from "../contract/console-url.js";
4
+ import { cliApiMessages } from "../contract/cli-api-messages.js";
5
+ import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
6
+ import { CLI_BODY_LIMIT } from "../cli/api-types.js";
7
+ export { DEFAULT_CONSOLE_URL as DEFAULT_URL } from "../setup-pings/client.js";
8
+ export class ApiError extends Error {
9
+ status;
10
+ code;
11
+ retryAfterMs;
12
+ constructor(status, code, message, retryAfterMs = 0) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.retryAfterMs = retryAfterMs;
17
+ }
18
+ }
19
+ const emailRoutes = {
20
+ "email/start": { fields: ["email"], successStatus: 202, failures: { invalid_email: 400 }, jsonCooldown: true },
21
+ "email/verify": { fields: ["email", "code", "clientName"], successStatus: 200,
22
+ failures: { wrong_code: 400, expired: 400, too_many_attempts: 429 }, jsonCooldown: false },
23
+ };
24
+ // Longer cooldowns keep the server's delay but use the sentence without a duration.
25
+ const MAX_DISPLAYED_EMAIL_COOLDOWN_MS = 24 * 60 * 60 * 1000;
26
+ function retryAfterMs(value, rejectNegativeSeconds = false) {
27
+ if (!value)
28
+ return undefined;
29
+ const seconds = Number(value);
30
+ // Date parsing also accepts negative number strings, which are not valid email cooldowns.
31
+ if (rejectNegativeSeconds && seconds < 0)
32
+ return undefined;
33
+ if (Number.isFinite(seconds) && seconds >= 0)
34
+ return seconds * 1000;
35
+ const date = Date.parse(value);
36
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
37
+ }
38
+ function requestBody(path, body, emailRoute) {
39
+ if ((path === "balance" || path === "checks/mode" || /^findings\/[a-zA-Z0-9_-]+$/.test(path)) && body === undefined)
40
+ return undefined;
41
+ if (body && typeof body === "object" && !Array.isArray(body)) {
42
+ const fields = Object.keys(body);
43
+ if (path === "device" && fields.length === 0)
44
+ return {};
45
+ if (path === "device/token" && fields.length === 1 && fields[0] === "deviceCode") {
46
+ const deviceCode = body.deviceCode;
47
+ if (typeof deviceCode === "string")
48
+ return { deviceCode };
49
+ }
50
+ if (emailRoute && fields.length === emailRoute.fields.length && fields.every(field => emailRoute.fields.includes(field))) {
51
+ const selected = {};
52
+ for (const field of emailRoute.fields) {
53
+ const value = body[field];
54
+ if (typeof value !== "string")
55
+ throw new Error(cliApiMessages.request_failed);
56
+ selected[field] = value;
57
+ }
58
+ return selected;
59
+ }
60
+ }
61
+ throw new Error(cliApiMessages.request_failed);
62
+ }
63
+ function emailFailure(route, response, value) {
64
+ if (response.status === 404)
65
+ return new ApiError(404, "email_unavailable", emailSignInMessages.email_unavailable);
66
+ const failure = value && typeof value === "object" && !Array.isArray(value)
67
+ ? value : undefined;
68
+ const code = failure?.error;
69
+ if (typeof code === "string" && Object.hasOwn(route.failures, code) && route.failures[code] === response.status) {
70
+ return new ApiError(response.status, code, emailSignInMessages[code]);
71
+ }
72
+ if (response.status === 429 && code === "rate_limited") {
73
+ let cooldown = retryAfterMs(response.headers.get("retry-after"), true);
74
+ if (route.jsonCooldown && typeof failure?.retryAfter === "number" && failure.retryAfter >= 0 && Number.isFinite(failure.retryAfter * 1000)) {
75
+ cooldown = failure.retryAfter * 1000;
76
+ }
77
+ if (cooldown !== undefined && !Number.isFinite(cooldown))
78
+ cooldown = undefined;
79
+ return new ApiError(429, code, cooldown === undefined || cooldown > MAX_DISPLAYED_EMAIL_COOLDOWN_MS ? emailSignInMessages.rate_limited
80
+ : emailSignInMessages.rate_limited_with_cooldown(Math.ceil(cooldown / 1000)), cooldown ?? 0);
81
+ }
82
+ return new ApiError(response.status, "request_failed", emailSignInMessages.request_failed);
83
+ }
84
+ export class ApiClient {
85
+ token;
86
+ fetcher;
87
+ signal;
88
+ baseUrl;
89
+ constructor(baseUrl, token, fetcher = fetch, signal) {
90
+ this.token = token;
91
+ this.fetcher = fetcher;
92
+ this.signal = signal;
93
+ const url = new URL(baseUrl);
94
+ if (!consoleSchemeAllowed(url) || url.username || url.password || url.search || url.hash)
95
+ throw new Error("COUNTERFACT_URL must be an HTTP or HTTPS address without credentials or query fields.");
96
+ this.baseUrl = url.href.replace(/\/+$/, "");
97
+ }
98
+ async request(path, body, authenticated = true) {
99
+ const emailRoute = Object.hasOwn(emailRoutes, path) ? emailRoutes[path] : undefined;
100
+ const throwIfAborted = () => {
101
+ if (emailRoute && this.signal?.aborted)
102
+ throw new DOMException(emailSignInMessages.cancelled, "AbortError");
103
+ this.signal?.throwIfAborted();
104
+ };
105
+ throwIfAborted();
106
+ const safeBody = requestBody(path, body, emailRoute);
107
+ const headers = { accept: "application/json", "x-counterfact-cli-version": CLI_VERSION };
108
+ if (authenticated) {
109
+ const token = await this.token();
110
+ if (!token)
111
+ throw new Error("Not signed in. Run counterfact-labs login.");
112
+ headers.authorization = `Bearer ${token}`;
113
+ }
114
+ if (body !== undefined)
115
+ headers["content-type"] = "application/json";
116
+ const serialized = body === undefined ? undefined : JSON.stringify(safeBody);
117
+ if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") > CLI_BODY_LIMIT) {
118
+ throw new ApiError(413, "body_too_large", cliRefusals.body_too_large);
119
+ }
120
+ const controller = new AbortController();
121
+ const cancel = () => controller.abort(this.signal?.reason);
122
+ const timeout = setTimeout(() => controller.abort(), 120_000);
123
+ timeout.unref();
124
+ this.signal?.addEventListener("abort", cancel, { once: true });
125
+ try {
126
+ throwIfAborted();
127
+ let response;
128
+ try {
129
+ response = await this.fetcher(`${this.baseUrl}/api/cli/${path}`, {
130
+ method: body === undefined ? "GET" : "POST", headers,
131
+ body: serialized,
132
+ redirect: "error", signal: controller.signal,
133
+ });
134
+ }
135
+ catch {
136
+ throwIfAborted();
137
+ throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
138
+ }
139
+ throwIfAborted();
140
+ if (emailRoute && response.ok && response.status !== emailRoute.successStatus) {
141
+ // Cleanup failures must not replace the safe invalid-response sentence.
142
+ await response.body?.cancel().catch(() => undefined);
143
+ throw new Error(emailSignInMessages.invalid_response);
144
+ }
145
+ let value;
146
+ try {
147
+ value = await response.json();
148
+ }
149
+ catch (error) {
150
+ throwIfAborted();
151
+ if (response.ok) {
152
+ if (error instanceof SyntaxError)
153
+ throw new Error(cliApiMessages.invalid_response);
154
+ throw new ApiError(0, "unreachable", cliApiMessages.unreachable);
155
+ }
156
+ }
157
+ throwIfAborted();
158
+ if (emailRoute && !response.ok)
159
+ throw emailFailure(emailRoute, response, value);
160
+ if (!response.ok) {
161
+ const error = value;
162
+ throw new ApiError(response.status, error?.code ?? "request_failed", error?.code && Object.hasOwn(cliRefusals, error.code) ? cliRefusals[error.code]
163
+ : typeof error?.error === "string" ? error.error : cliApiMessages.request_failed, retryAfterMs(response.headers.get("retry-after")) ?? 0);
164
+ }
165
+ return value;
166
+ }
167
+ finally {
168
+ clearTimeout(timeout);
169
+ this.signal?.removeEventListener("abort", cancel);
170
+ }
171
+ }
172
+ }
173
+ export function errorCode(error) { return refusalExitCode(error instanceof ApiError ? error.code : undefined); }
174
+ export function errorText(error) { return error instanceof Error ? error.message : cliApiMessages.request_failed; }
@@ -0,0 +1,76 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { relayAnswer, relayRequest } from "../contract/messages.js";
3
+ import { verifyAttestation } from "./attestation.js";
4
+ const BUSY_INITIAL_BACKOFF_MS = 250;
5
+ const BUSY_MAX_BACKOFF_MS = 5_000;
6
+ export class GatekeeperClientError extends Error {
7
+ name = "GatekeeperClientError";
8
+ }
9
+ export class GatekeeperClient {
10
+ options;
11
+ constructor(options) {
12
+ this.options = options;
13
+ }
14
+ async call(request, sendOptions = {}) {
15
+ const beforeSend = () => {
16
+ if (sendOptions.signal?.aborted || this.options.deadlineMs && this.options.deadlineMs() <= Date.now())
17
+ throw new GatekeeperClientError("Relay request cancelled");
18
+ sendOptions.beforeSend?.();
19
+ };
20
+ beforeSend();
21
+ const outgoing = relayRequest.safeParse({ ...request, nonce: randomBytes(32).toString("hex") });
22
+ if (!outgoing.success)
23
+ throw new GatekeeperClientError("Invalid relay request");
24
+ const options = this.options;
25
+ if (options.mode === "development") {
26
+ let token;
27
+ try {
28
+ token = await options.connection.attestation();
29
+ }
30
+ catch {
31
+ throw new GatekeeperClientError("Unable to read relay attestation");
32
+ }
33
+ verifyAttestation(token, options.connection.channelPublicKey, options.policy);
34
+ }
35
+ let backoffMs = BUSY_INITIAL_BACKOFF_MS;
36
+ while (true) {
37
+ let incoming;
38
+ try {
39
+ beforeSend();
40
+ incoming = await options.connection.send(outgoing.data, { ...sendOptions, beforeSend });
41
+ }
42
+ catch {
43
+ // Transport errors can include request bodies, so do not retain their text or cause.
44
+ throw new GatekeeperClientError("Unable to send relay request");
45
+ }
46
+ const answer = relayAnswer.safeParse(incoming);
47
+ if (!answer.success)
48
+ throw new GatekeeperClientError("Invalid relay answer");
49
+ if (answer.data.nonce !== outgoing.data.nonce)
50
+ throw new GatekeeperClientError("Relay answer nonce mismatch");
51
+ if (answer.data.ok || answer.data.code !== "busy" || !options.deadlineMs)
52
+ return answer.data;
53
+ const remainingMs = options.deadlineMs() - Date.now();
54
+ if (remainingMs <= 0)
55
+ return answer.data;
56
+ // Busy has not started provider work. Keep its call identity and retry only within this run's lease.
57
+ await this.waitForCapacity(Math.min(backoffMs, remainingMs), sendOptions.signal);
58
+ if (options.deadlineMs() <= Date.now())
59
+ return answer.data;
60
+ backoffMs = Math.min(backoffMs * 2, BUSY_MAX_BACKOFF_MS);
61
+ outgoing.data = { ...outgoing.data, nonce: randomBytes(32).toString("hex") };
62
+ }
63
+ }
64
+ async waitForCapacity(waitMs, signal) {
65
+ if (signal?.aborted)
66
+ throw new GatekeeperClientError("Relay request cancelled");
67
+ await new Promise((resolve, reject) => {
68
+ const abort = () => {
69
+ clearTimeout(timer);
70
+ reject(new GatekeeperClientError("Relay request cancelled"));
71
+ };
72
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, waitMs);
73
+ signal?.addEventListener("abort", abort, { once: true });
74
+ });
75
+ }
76
+ }
@@ -0,0 +1,60 @@
1
+ import { RELAY_MAX_FRAME_BYTES, deliveryReceipt, relayAnswer, relayAnswerFrame, relayRequest } from "../contract/messages.js";
2
+ export class RelayConnectionError extends Error {
3
+ name = "RelayConnectionError";
4
+ constructor() { super("Relay connection failed"); }
5
+ }
6
+ /** Calls and delivery retries share the authenticated transport and run in input order. */
7
+ export function createRelayExchange(options) {
8
+ let queue = Promise.resolve();
9
+ async function sendOne(input) {
10
+ try {
11
+ options.valid();
12
+ const request = relayRequest.parse(input);
13
+ if (!request.callId)
14
+ throw new Error();
15
+ // Provider work can exceed a network-body timeout; evidence expiry still closes this exact socket.
16
+ const response = await options.exchange("/relay", request, RELAY_MAX_FRAME_BYTES, options.remainingEvidenceMs());
17
+ const frame = options.development ? { answer: relayAnswer.parse(response.body), delivery: undefined } : relayAnswerFrame.parse(response.body);
18
+ if (frame.answer.nonce !== request.nonce)
19
+ throw new Error();
20
+ if (frame.delivery) {
21
+ const { confirmation } = frame.delivery;
22
+ if (confirmation.callId !== request.callId)
23
+ throw new Error();
24
+ // The relay enforces its deadline on its own clock; local clock drift must not discard a paid answer.
25
+ let acknowledged = false;
26
+ for (let attempt = 0; attempt < 2; attempt++) {
27
+ options.valid();
28
+ const receipt = await options.exchange("/delivery", confirmation, 1024);
29
+ if (receipt.status === 200) {
30
+ deliveryReceipt.parse(receipt.body);
31
+ acknowledged = true;
32
+ break;
33
+ }
34
+ }
35
+ if (!acknowledged)
36
+ throw new Error();
37
+ }
38
+ return frame.answer;
39
+ }
40
+ catch {
41
+ options.close();
42
+ throw new RelayConnectionError();
43
+ }
44
+ }
45
+ return (request, sendOptions = {}) => {
46
+ // Snapshot before queuing so overlapping callers cannot alter later writes.
47
+ const parsed = relayRequest.safeParse(request);
48
+ if (!parsed.success)
49
+ return Promise.reject(new RelayConnectionError());
50
+ const pending = queue.then(() => {
51
+ // Cancellation stops queued work, but a dispatched answer must finish delivery before closing.
52
+ if (sendOptions.signal?.aborted)
53
+ throw new RelayConnectionError();
54
+ sendOptions.beforeSend?.();
55
+ return sendOne(parsed.data);
56
+ });
57
+ queue = pending.catch(() => { });
58
+ return pending;
59
+ };
60
+ }