@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,51 @@
1
+ import { executeStructuredRequest } from "../analysis-core/llm/execution.js";
2
+ import { findingHeadlinesEnabled } from "../analysis-core/settings.js";
3
+ import { headlineRequest } from "../analysis-core/analysis/headlines/input.js";
4
+ import { retryCalls } from "../analysis-core/llm/slots.js";
5
+ import { relayModel, ModelConfigurationError } from "../gatekeeper/model.js";
6
+ import { readableRequestTools, readableInstructions } from "./instructions.js";
7
+ import { loadPublicRequestTools } from "./public-instructions.js";
8
+ export async function sourceModel(settings, apiKey, llm) {
9
+ if (!llm && !apiKey?.trim())
10
+ throw new ModelConfigurationError("Set ANTHROPIC_API_KEY to run local analysis.");
11
+ const provider = llm ?? (await import("./anthropic.js")).anthropicLlm(settings, { apiKey, sourceRetries: true });
12
+ return localModel(provider);
13
+ }
14
+ export { ModelConfigurationError } from "../gatekeeper/model.js";
15
+ export function localModel(llm, requests = readableRequestTools()) {
16
+ return {
17
+ settleStartedOnError: llm.settleStartedOnError,
18
+ withRetry: retry => localModel(retryCalls(llm, retry), requests),
19
+ execute(request, options = {}) {
20
+ return executeStructuredRequest(llm, requests, request, options);
21
+ },
22
+ };
23
+ }
24
+ // Transport and planning must use the same release before any customer files are read.
25
+ export async function selectAnalysisExecution(transport, source, relay) {
26
+ if (transport.CF_LOCAL_RELAY !== "1") {
27
+ return { model: await source(), requestTools: readableRequestTools(), instructions: async () => readableInstructions() };
28
+ }
29
+ if (!relay)
30
+ throw new ModelConfigurationError("Relay mode requires relay configuration");
31
+ if (!relay.instructions)
32
+ throw new ModelConfigurationError("Relay mode requires signed instruction metadata");
33
+ let requestTools;
34
+ try {
35
+ requestTools = loadPublicRequestTools(relay.instructions.bundle, relay.instructions.verificationKey);
36
+ }
37
+ catch {
38
+ throw new ModelConfigurationError("Invalid signed instruction metadata");
39
+ }
40
+ if (relay.bundleVersion !== requestTools.bundleVersion)
41
+ throw new ModelConfigurationError("Relay bundle version does not match signed instruction metadata");
42
+ return { model: relayModel({ ...relay, bundleVersion: requestTools.bundleVersion }), requestTools,
43
+ instructions: async () => { throw new Error("Readable instructions are unavailable in relay mode"); } };
44
+ }
45
+ export async function analysisExecution(model, options = {}, settings = {}) {
46
+ const execution = options.execution ?? await selectAnalysisExecution(options.transport ?? {}, () => "execute" in model ? model : localModel(model), options.relay);
47
+ // Validate enabled result support before earlier stages can spend, even for supplied executions.
48
+ if (findingHeadlinesEnabled(settings))
49
+ execution.requestTools.headlinePlan(headlineRequest([]));
50
+ return execution;
51
+ }
@@ -0,0 +1,92 @@
1
+ import { lstat, mkdir, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { FINISH_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
4
+ import { resolveFolderPath } from "./folder-path.js";
5
+ import { durableWrite } from "./durable-write.js";
6
+ export class OutputPathError extends Error {
7
+ constructor() {
8
+ super(FINISH_FAILURE_MESSAGE);
9
+ }
10
+ }
11
+ const missing = (error) => error.code === "ENOENT";
12
+ const within = (base, path) => {
13
+ const rel = relative(base, path);
14
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
15
+ };
16
+ /** Validate the selected anchor and every output component before creating or replacing files. */
17
+ export async function outputPaths(folder, names, outputDir, create = false) {
18
+ const selected = resolve(folder), root = await resolveFolderPath(folder);
19
+ const requested = resolve(outputDir ?? join(selected, ".counterfact"));
20
+ let directory = within(selected, requested) ? resolve(root, relative(selected, requested)) : requested;
21
+ const external = !within(root, directory);
22
+ const userUid = process.getuid?.();
23
+ let systemUid;
24
+ let anchor = root;
25
+ while (!within(anchor, directory))
26
+ anchor = dirname(anchor);
27
+ try {
28
+ // Canonicalize both coordinates before comparing them; aliases of the selected base are safe.
29
+ const canonicalAnchor = await realpath(anchor);
30
+ directory = resolve(canonicalAnchor, relative(anchor, directory));
31
+ anchor = canonicalAnchor;
32
+ const components = [anchor];
33
+ for (const part of relative(anchor, directory).split(sep).filter(Boolean))
34
+ components.push(join(components.at(-1), part));
35
+ for (const path of components) {
36
+ if (create && path !== anchor) {
37
+ try {
38
+ await mkdir(path, { mode: 0o700 });
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "EEXIST")
42
+ throw error;
43
+ }
44
+ }
45
+ const info = await lstat(path);
46
+ if (!info.isDirectory())
47
+ throw new OutputPathError();
48
+ if (userUid !== undefined && info.uid !== userUid) {
49
+ // External output may cross protected system parents, including sticky temporary directories.
50
+ const sharedSystemParent = external && path !== directory &&
51
+ ((info.mode & 0o022) === 0 || (info.mode & 0o1000) !== 0);
52
+ if (!sharedSystemParent)
53
+ throw new OutputPathError();
54
+ // Containers can map system uids. User-owned trees need no access outside their permission boundary.
55
+ systemUid ??= (await lstat(parse(directory).root)).uid;
56
+ if (info.uid !== systemUid)
57
+ throw new OutputPathError();
58
+ }
59
+ }
60
+ const paths = [];
61
+ for (const name of names) {
62
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name))
63
+ throw new OutputPathError();
64
+ const path = join(directory, name);
65
+ try {
66
+ const info = await lstat(path);
67
+ if (!info.isFile() || userUid !== undefined && info.uid !== userUid)
68
+ throw new OutputPathError();
69
+ }
70
+ catch (error) {
71
+ if (!missing(error))
72
+ throw error;
73
+ }
74
+ paths.push(path);
75
+ }
76
+ return paths;
77
+ }
78
+ catch (error) {
79
+ if (!create && missing(error))
80
+ throw error;
81
+ throw new OutputPathError();
82
+ }
83
+ }
84
+ export async function writeOutputFiles(folder, files, outputDir) {
85
+ const names = Object.keys(files);
86
+ const paths = await outputPaths(folder, names, outputDir, true);
87
+ for (let index = 0; index < paths.length; index++) {
88
+ await outputPaths(folder, names, outputDir);
89
+ await durableWrite(paths[index], files[names[index]]);
90
+ }
91
+ return paths;
92
+ }
@@ -0,0 +1,28 @@
1
+ import { readJobSettings } from "./settings.js";
2
+ export { promptCacheEnabled } from "../analysis-core/llm/cache-policy.js";
3
+ import { promptCachePolicy } from "../analysis-core/llm/cache-policy.js";
4
+ export function promptCacheSettings(enabled) {
5
+ return readJobSettings({ CF_PROMPT_CACHE: enabled ? "1" : "0" });
6
+ }
7
+ /** A block boundary must never insert whitespace into the original text. */
8
+ export function textOf(text) {
9
+ return typeof text === "string" ? text : text.map(b => b.text).join("");
10
+ }
11
+ export function anthropicText(text) {
12
+ if (typeof text === "string")
13
+ return text;
14
+ if (text.filter(block => block.cache).length > 4)
15
+ throw new Error("A request can have at most four cache breakpoints.");
16
+ return text.map(block => ({ type: "text", text: block.text,
17
+ ...(block.cache ? { cache_control: { type: "ephemeral" } } : {}) }));
18
+ }
19
+ /** Validate even with caching off so estimates and the request cannot drift. */
20
+ export function structuredPromptOf(settings, call) {
21
+ if (call.promptBlocks && textOf(call.promptBlocks) !== call.prompt)
22
+ throw new Error("Structured text blocks must match the prompt.");
23
+ const policy = promptCachePolicy(settings, { promptBlocks: call.promptBlocks });
24
+ return anthropicText(policy.enabled ? call.promptBlocks ?? call.prompt : call.prompt);
25
+ }
26
+ export function requestCachesInput(settings, call) {
27
+ return promptCachePolicy(settings, { promptBlocks: call.promptBlocks }).ttl !== null;
28
+ }
@@ -0,0 +1,36 @@
1
+ import { findingHeadlinesEnabled } from "../analysis-core/settings.js";
2
+ import { callInputBytes } from "../analysis-core/requests/registry.js";
3
+ import { createTemplateVersion } from "../analysis-core/requests/template-version.js";
4
+ import { checkDiagnosticsText } from "../analysis-core/requests/check-diagnostics.js";
5
+ import { shapeFindingData, assertShapeFindingBytes } from "../analysis-core/requests/shape-finding-input.js";
6
+ import { HEADLINE_RESULT_VERSION } from "../analysis-core/analysis/headlines/types.js";
7
+ import { sealedBundle } from "../contract/messages.js";
8
+ import { verifyRelease } from "../bundle/seal/signature.js";
9
+ export function loadPublicRequestTools(value, verificationKey, settings = {}) {
10
+ const bundle = sealedBundle.parse(value);
11
+ verifyRelease(bundle, verificationKey);
12
+ const versions = createTemplateVersion(bundle.metadata);
13
+ const renderingUnavailable = () => { throw new Error("Instruction rendering requires the sealed relay"); };
14
+ if (findingHeadlinesEnabled(settings) && bundle.metadata.headlinePlan?.resultVersion !== HEADLINE_RESULT_VERSION)
15
+ renderingUnavailable();
16
+ return { ...versions, bundleVersion: bundle.version, modelReaderPrompt: renderingUnavailable, callInputBytes, checkDiagnosticsText,
17
+ buildAnalysisCall: renderingUnavailable,
18
+ headlinePlan(request) {
19
+ const plan = bundle.metadata.headlinePlan;
20
+ if (plan?.resultVersion !== HEADLINE_RESULT_VERSION)
21
+ return renderingUnavailable();
22
+ const data = JSON.stringify(request.inputs.evidence);
23
+ return { inputBytes: plan.inputOverheadBytes + plan.evidenceCopies * Buffer.byteLength(data),
24
+ identity: { registry: plan.fingerprint, resultVersion: plan.resultVersion, data }, relayValidated: true };
25
+ },
26
+ // Relay audit counts describe the envelope; rendered ceilings are enforced inside the relay.
27
+ checkPayloadBytes: input => Buffer.byteLength(JSON.stringify(input)),
28
+ validateShapeInput({ findings }) {
29
+ if (!findings.length)
30
+ return;
31
+ const bytes = versions.templateBytes("analysis-split-check-finding-input-1") - Buffer.byteLength("{{v0}}")
32
+ + Buffer.byteLength(shapeFindingData(findings)) + Buffer.byteLength("\n\n");
33
+ assertShapeFindingBytes(bytes);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,6 @@
1
+ import { readFindingQualityConfig as readSharedConfig } from "../analysis-core/analysis/finding-quality/config.js";
2
+ import { readableRequestTools } from "./instructions.js";
3
+ // Local startup has one provider and has not approved recall or imported code evidence.
4
+ export function readLocalQualityConfig(settings, tools = readableRequestTools()) {
5
+ return readSharedConfig(settings, tools, { codeInputs: false, recall: false, secondOpinion: false });
6
+ }
@@ -0,0 +1,15 @@
1
+ import { sealedBundleHash } from "../bundle/seal/signature.js";
2
+ import { checkPlannedCallsAgainstBundle } from "../bundle/seal/planned-calls.js";
3
+ import { localCallPlan } from "../analysis-core/requests/local-plan.js";
4
+ import { relayPlanSchema } from "../contract/console.js";
5
+ /** Callers authenticate the release before reading files; only this owner turns source facts into a paid plan. */
6
+ export function prepareRelayPlan({ bundle, tools, relayVersion, settings, source }) {
7
+ const files = Object.entries(source.files).map(([path, text]) => ({ path, size: Buffer.byteLength(text) }));
8
+ const calls = localCallPlan(settings, files, {
9
+ structureState: { repos: [{ key: source.repoKey, kind: "fs", files: source.files, ...(source.root !== undefined ? { root: source.root } : {}) }], mapped: [source.fragment] },
10
+ });
11
+ checkPlannedCallsAgainstBundle(bundle, calls);
12
+ return { files, plan: relayPlanSchema.parse({ bundleVersion: tools.bundleVersion,
13
+ bundleHash: sealedBundleHash(bundle), relayVersion,
14
+ calls: calls.map(({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens }) => ({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens })) }) };
15
+ }
@@ -0,0 +1,57 @@
1
+ import { createHash, createPublicKey } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { sealedBundle } from "../contract/messages.js";
4
+ import { loadPublicRequestTools } from "./public-instructions.js";
5
+ import { relayOrigin } from "../gatekeeper/connection.js";
6
+ import { z } from "zod";
7
+ import { cliRefusals } from "../contract/cli-refusals.js";
8
+ import { RELEASE_KEY_SPKI_SHA256 } from "../contract/release-key.js";
9
+ const releasePins = z.object({ verificationKey: z.string().min(1), relayVersion: z.string().min(1), localVersion: z.string().min(1),
10
+ relayUrl: z.string().min(1), projectContext: z.literal("checks") });
11
+ export function readClientReleasePins(value) {
12
+ const pins = releasePins.parse(value);
13
+ relayOrigin(pins.relayUrl);
14
+ return Object.freeze(pins);
15
+ }
16
+ /** Injected releases must pass the same checks as releases loaded from disk. */
17
+ export function validateClientRelease(release, settings = {}) {
18
+ const bundle = sealedBundle.parse(release.bundle);
19
+ const tools = loadPublicRequestTools(bundle, release.verificationKey, settings);
20
+ if (!bundle.relayVersions.includes(release.relayVersion))
21
+ throw new Error();
22
+ return { bundle, tools };
23
+ }
24
+ export function readClientReleaseKey(pem) {
25
+ const key = createPublicKey(pem);
26
+ const hash = createHash("sha256").update(key.export({ type: "spki", format: "der" })).digest("hex");
27
+ if (hash !== RELEASE_KEY_SPKI_SHA256)
28
+ throw new Error(cliRefusals.release_unavailable);
29
+ return key;
30
+ }
31
+ /** Installed releases always use the separately reviewed signing key. */
32
+ export function loadInstalledRelease(paths) {
33
+ return loadRelease(paths, false);
34
+ }
35
+ /** Source-only development startup already requires explicit paths and development trust. */
36
+ export function loadDevelopmentRelease(paths) {
37
+ return loadRelease(paths, true);
38
+ }
39
+ async function loadRelease(paths, development) {
40
+ try {
41
+ const base = new URL(import.meta.url.endsWith(".ts") ? "../bundle/" : "../../bundle/", import.meta.url);
42
+ const metadata = JSON.parse(await readFile(paths?.metadata ?? new URL("client-release.json", base), "utf8"));
43
+ const pins = paths ? metadata : readClientReleasePins(metadata);
44
+ if (typeof pins.verificationKey !== "string" || typeof pins.relayVersion !== "string" || !pins.relayVersion ||
45
+ typeof pins.localVersion !== "string" || !pins.localVersion)
46
+ throw new Error();
47
+ const verificationKey = development ? createPublicKey(pins.verificationKey) : readClientReleaseKey(pins.verificationKey);
48
+ const bundle = JSON.parse(await readFile(paths?.bundle ?? new URL("sealed.json", base), "utf8"));
49
+ const release = { bundle, verificationKey, relayVersion: pins.relayVersion, localVersion: pins.localVersion,
50
+ relayUrl: pins.relayUrl, projectContext: pins.projectContext };
51
+ validateClientRelease(release);
52
+ return release;
53
+ }
54
+ catch {
55
+ throw new Error(cliRefusals.release_unavailable);
56
+ }
57
+ }
@@ -0,0 +1,29 @@
1
+ import { rankFindings } from "../analysis-core/analysis/finding-quality/policy.js";
2
+ import { attachHeadlineResults } from "../analysis-core/analysis/headlines/attachment.js";
3
+ /** The local view keeps presentation ranking and reading diagnostics beside the shared result. */
4
+ export function buildLocalReport(repoKey, result, state) {
5
+ const quality = result.findingQuality;
6
+ const checked = { ...(state.findingCheck?.state?.result ?? { kept: result.findings, dropped: [], warnings: [] }) };
7
+ // Source explanations refresh the audit without changing the saved checker decision.
8
+ if (quality?.assessments && checked.qualityAudit)
9
+ checked.qualityAudit = {
10
+ coverage: quality.coverage ?? [], verdicts: quality.verdicts ?? [], assessments: quality.assessments,
11
+ ...(quality.shadow ? { shadow: quality.shadow } : {}),
12
+ };
13
+ const attached = attachHeadlineResults(result.findings, result.structures, result.findingHeadlines ?? [], result.findingHeadlineDiagnostics);
14
+ const wording = new Map(attached.rows.filter(row => row.target === "finding")
15
+ .map(row => [row.index, { headline: row.headline, ifLeft: row.ifLeft }]));
16
+ const structureWording = new Map(attached.rows.filter(row => row.target === "structure")
17
+ .map(row => [row.index, { headline: row.headline, ifLeft: row.ifLeft }]));
18
+ const structures = structureWording.size ? result.structures.map((proposal, index) => structureWording.has(index)
19
+ ? { ...proposal, ...structureWording.get(index) } : proposal) : result.structures;
20
+ const findings = wording.size
21
+ ? result.findings.map((finding, index) => wording.has(index) ? { ...finding, ...wording.get(index) } : finding)
22
+ : result.findings;
23
+ const ranked = state.findingQuality?.config.switches.ranking ? rankFindings(findings, quality?.assessments ?? []) : undefined;
24
+ return { repoKey, findings: ranked?.ordered ?? findings, structures, graph: result.graph, warnings: result.warnings,
25
+ ...(attached.diagnostics ? { findingHeadlineDiagnostics: attached.diagnostics } : {}),
26
+ skippedFiles: state.inspectPlan?.skipped ?? [], passes: state.inspectPlan?.passes.length ?? 1, findingCheck: checked,
27
+ ...(ranked ? { ranking: { defaultIds: ranked.defaultIds, hiddenIds: ranked.hiddenIds, emptyMessage: ranked.emptyMessage } } : {}),
28
+ };
29
+ }
@@ -0,0 +1,199 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { READ_LIMITS } from "../analysis-core/import/outside-loader-sources.js";
5
+ import { admitFile, FileReadRefusal, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
6
+ import { createGitIgnore } from "./git-ignore.js";
7
+ import { createIgnoredFileCollector } from "./ignored-files.js";
8
+ import { searchBaseMatches, searchBoundary } from "./repository-root.js";
9
+ /** Construction does no I/O. Discovery is initialized only by the owner's request. */
10
+ export function createRepositoryFiles(settings, folder, admission = {}, options = {}) {
11
+ const guarded = { ...settings, CF_LOCAL_SYMLINK_GUARD: "1" };
12
+ let contextPromise;
13
+ let accepted = null;
14
+ let listingPromise;
15
+ const listed = new Set();
16
+ const snapshot = {};
17
+ const skippedFiles = [];
18
+ const ignoredPaths = createGitIgnore(settings, folder);
19
+ const ignoredFiles = createIgnoredFileCollector(READ_LIMITS.archive.files);
20
+ const context = () => contextPromise ??= (async () => {
21
+ if (Object.hasOwn(options, "expectedRepositoryRoot") && !options.expectedRepositoryRoot)
22
+ return null;
23
+ const boundary = await searchBoundary(folder);
24
+ if (!boundary || Object.hasOwn(options, "expectedRepositoryRoot") && !await searchBaseMatches(boundary.base, options.expectedRepositoryRoot))
25
+ return null;
26
+ const rebased = { ...admission, ...(admission.exclusions ? { exclusions: admission.exclusions.map(path => boundary.selectedRoot ? `${boundary.selectedRoot}/${path}` : path) } : {}) };
27
+ const excludedTargets = await resolveLocalExclusions(rebased, async (path) => {
28
+ try {
29
+ return await realpath(join(boundary.base, path));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ });
35
+ accepted = { ...boundary, admission: rebased, excludedTargets };
36
+ return accepted;
37
+ })();
38
+ const repoPath = (base, target) => relative(base, target).split(sep).join("/");
39
+ const admit = async (ctx, path, target, size, directory = false) => {
40
+ const ask = { purpose: "listing", route: "local-folder", ...ctx.admission, localTarget: { root: ctx.base, path: target, excludedTargets: ctx.excludedTargets } };
41
+ const lexical = admitFile(guarded, { path, size, directory }, ask);
42
+ // An innocent alias cannot hide a secret or a hidden directory in its target.
43
+ const canonical = lexical.admitted ? admitFile(guarded, { path: repoPath(ctx.base, target), size, directory }, ask) : lexical;
44
+ if (!canonical.admitted)
45
+ return canonical;
46
+ const gitIgnored = await ignoredPaths([join(ctx.base, path), target]);
47
+ const allowed = admitFile(guarded, { path, size, directory }, { ...ask, gitIgnored });
48
+ if (!allowed.admitted) {
49
+ if (directory)
50
+ skippedFiles.push(...await ignoredFiles(join(ctx.base, path), path));
51
+ else
52
+ skippedFiles.push({ path, reason: allowed.reason });
53
+ }
54
+ return allowed;
55
+ };
56
+ const list = () => listingPromise ??= (async () => {
57
+ const ctx = await context();
58
+ if (!ctx)
59
+ return { entries: [], entriesVisited: 0, truncated: false };
60
+ const candidates = [];
61
+ const directories = new Set();
62
+ let entriesVisited = 0, truncated = false;
63
+ async function walk(dir, prefix) {
64
+ if (directories.has(dir))
65
+ return;
66
+ directories.add(dir);
67
+ let handle;
68
+ try {
69
+ handle = await opendir(dir);
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ try {
75
+ while (true) {
76
+ if (entriesVisited >= READ_LIMITS.archive.files) {
77
+ truncated = true;
78
+ return;
79
+ }
80
+ const item = await handle.read();
81
+ if (!item)
82
+ return;
83
+ entriesVisited++;
84
+ const path = prefix ? `${prefix}/${item.name}` : item.name;
85
+ // A link's filename-only refusal waits until its target type is known.
86
+ const listed = { path, directory: item.isDirectory() || item.isSymbolicLink() };
87
+ if (!admitFile(guarded, listed, { purpose: "listing", route: "local-folder", ...ctx.admission }).admitted)
88
+ continue;
89
+ try {
90
+ const lexical = join(dir, item.name);
91
+ const facts = await ignoredPaths([lexical]);
92
+ const gitIgnored = facts && new Set(facts.has(lexical) ? [path] : []);
93
+ const allowed = admitFile(guarded, listed, { purpose: "listing", route: "local-folder", ...ctx.admission, gitIgnored });
94
+ if (!allowed.admitted) {
95
+ if (item.isDirectory())
96
+ skippedFiles.push(...await ignoredFiles(join(dir, item.name), path));
97
+ else if (item.isFile() || item.isSymbolicLink())
98
+ skippedFiles.push({ path, reason: allowed.reason });
99
+ continue;
100
+ }
101
+ const target = await realpath(join(dir, item.name));
102
+ const info = await stat(target);
103
+ if (!(await admit(ctx, path, target, undefined, info.isDirectory())).admitted)
104
+ continue;
105
+ if (info.isDirectory()) {
106
+ // A nested marker establishes a different search boundary, even if invalid.
107
+ try {
108
+ await lstat(join(target, ".git"));
109
+ continue;
110
+ }
111
+ catch (error) {
112
+ if (error.code !== "ENOENT")
113
+ continue;
114
+ }
115
+ await walk(target, repoPath(ctx.base, target));
116
+ if (truncated)
117
+ return;
118
+ }
119
+ else if (info.isFile()) {
120
+ const canonical = repoPath(ctx.base, target);
121
+ candidates.push({ entry: { path: canonical, size: info.size, ref: canonical }, target });
122
+ }
123
+ }
124
+ catch { /* Missing, looping and unreadable targets supply no metadata. */ }
125
+ }
126
+ }
127
+ finally {
128
+ await handle.close();
129
+ }
130
+ }
131
+ await walk(ctx.base, "");
132
+ candidates.sort((a, b) => a.entry.path < b.entry.path ? -1 : a.entry.path > b.entry.path ? 1 : 0);
133
+ const targets = new Set();
134
+ const entries = [];
135
+ for (const { entry, target } of candidates) {
136
+ if (targets.has(target))
137
+ continue;
138
+ targets.add(target);
139
+ listed.add(entry.path);
140
+ entries.push(entry);
141
+ }
142
+ return { entries, entriesVisited, truncated };
143
+ })();
144
+ return {
145
+ snapshot, skippedFiles, get sourceBase() { return accepted?.base; }, get selectedRoot() { return accepted?.selectedRoot; },
146
+ finish(paths) {
147
+ const keep = new Set(paths);
148
+ for (const path of Object.keys(snapshot))
149
+ if (!keep.has(path))
150
+ delete snapshot[path];
151
+ },
152
+ context: async () => { const ctx = await context(); return ctx ? { selectedRoot: ctx.selectedRoot } : null; },
153
+ list,
154
+ async read(entry, maxBytes) {
155
+ let bytesRead = 0;
156
+ let handle;
157
+ try {
158
+ const ctx = await context();
159
+ if (!ctx || !listed.has(entry.path))
160
+ return { text: null, bytesRead, refusal: "out-of-scope" };
161
+ const target = await realpath(join(ctx.base, entry.path));
162
+ const allowed = await admit(ctx, entry.path, target, entry.size);
163
+ if (!allowed.admitted)
164
+ return { text: null, bytesRead, refusal: allowed.reason };
165
+ const boundary = await searchBoundary(dirname(target));
166
+ if (!boundary || !await searchBaseMatches(boundary.base, ctx.base))
167
+ return { text: null, bytesRead, refusal: "out-of-scope" };
168
+ const capacity = Math.min(maxBytes, READ_LIMITS.codeScan.fileBytes + 1);
169
+ if (!Number.isSafeInteger(capacity) || capacity <= 0)
170
+ return { text: null, bytesRead, refusal: "scan-limit" };
171
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
172
+ if (!(await handle.stat()).isFile())
173
+ return { text: null, bytesRead, refusal: "unreadable" };
174
+ const buffer = Buffer.alloc(capacity);
175
+ let complete = false;
176
+ while (bytesRead < capacity) {
177
+ const result = await handle.read(buffer, bytesRead, Math.min(65_536, capacity - bytesRead), null);
178
+ bytesRead += result.bytesRead;
179
+ if (!result.bytesRead) {
180
+ complete = true;
181
+ break;
182
+ }
183
+ }
184
+ if (!complete || bytesRead > entry.size || bytesRead > READ_LIMITS.codeScan.fileBytes)
185
+ return { text: null, bytesRead, refusal: "too-large" };
186
+ const bytes = buffer.subarray(0, bytesRead);
187
+ const text = await readRepoFile(guarded, entry, async () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), { ...ctx.admission, route: "local-folder", listing: listed, localTarget: { root: ctx.base, path: target, excludedTargets: ctx.excludedTargets } });
188
+ snapshot[entry.path] = bytes;
189
+ return { text, bytesRead };
190
+ }
191
+ catch (error) {
192
+ return { text: null, bytesRead, refusal: error instanceof FileReadRefusal && error.reason === "secret-content" ? error.reason : "unreadable" };
193
+ }
194
+ finally {
195
+ await handle?.close();
196
+ }
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,120 @@
1
+ import { access, constants, lstat, open, realpath, stat } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ export async function gitRepositoryRoot(git) {
4
+ try {
5
+ return (await git(["rev-parse", "--show-toplevel"])).replace(/\n$/, "");
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ /** A marker bounds filesystem discovery; it does not establish a valid Git checkout. */
12
+ export async function searchBoundary(folder) {
13
+ try {
14
+ const selected = await realpath(resolve(folder));
15
+ if (!(await stat(selected)).isDirectory())
16
+ return null;
17
+ let base = selected;
18
+ for (let inspected = 0; inspected < 64; inspected++) {
19
+ try {
20
+ // Inspect the marker itself, never its administrative pointer or a symlink target.
21
+ const marker = await lstat(join(base, ".git"));
22
+ if (!marker.isDirectory() && !marker.isFile())
23
+ return null;
24
+ return { base, selectedRoot: relative(base, selected).split(sep).join("/") };
25
+ }
26
+ catch (error) {
27
+ if (error.code !== "ENOENT")
28
+ return null;
29
+ }
30
+ const parent = dirname(base);
31
+ if (parent === base)
32
+ break;
33
+ base = parent;
34
+ }
35
+ return null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Whether a .git marker is one Git could use: a directory with a HEAD entry, or a "gitdir: " file. */
42
+ async function usableMarker(marker) {
43
+ try {
44
+ if ((await lstat(marker)).isDirectory()) {
45
+ try {
46
+ await lstat(join(marker, "HEAD"));
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ return error.code !== "ENOENT";
51
+ }
52
+ }
53
+ const file = await open(marker, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
54
+ try {
55
+ const head = Buffer.alloc(8);
56
+ await file.read(head, 0, 8, 0);
57
+ return head.toString("utf8") === "gitdir: ";
58
+ }
59
+ finally {
60
+ await file.close();
61
+ }
62
+ }
63
+ catch (error) {
64
+ // Only a confirmed absence rules a marker out; anything unreadable is still treated as a checkout.
65
+ return error.code !== "ENOENT";
66
+ }
67
+ }
68
+ /**
69
+ * Whether a checkout Git could use bounds this folder. Like Git, it skips stray markers (an empty
70
+ * .git directory, for example a leftover /tmp/.git) and keeps looking upward.
71
+ */
72
+ export async function usableCheckoutBoundary(folder) {
73
+ let base;
74
+ try {
75
+ base = await realpath(resolve(folder));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ for (let inspected = 0; inspected < 64; inspected++) {
81
+ try {
82
+ await lstat(join(base, ".git"));
83
+ if (await usableMarker(join(base, ".git")))
84
+ return true;
85
+ }
86
+ catch (error) {
87
+ const { code } = error;
88
+ // Outside a Node permission grant nothing can be inspected, as in searchBoundary; other errors are unknown checkouts.
89
+ if (code === "ERR_ACCESS_DENIED")
90
+ return false;
91
+ if (code !== "ENOENT")
92
+ return true;
93
+ }
94
+ const parent = dirname(base);
95
+ if (parent === base)
96
+ return false;
97
+ base = parent;
98
+ }
99
+ return true;
100
+ }
101
+ /** A Git failure means "standalone folder" only when no usable checkout bounds it. */
102
+ export async function standaloneGitFailure(folder, error) {
103
+ return [128, "ENOENT", "ERR_ACCESS_DENIED"].includes(error.code) && !await usableCheckoutBoundary(folder);
104
+ }
105
+ /** Relay coordinates are safe only when Git and the conservative boundary agree. */
106
+ export async function searchBaseMatches(base, repoRoot) {
107
+ if (!repoRoot)
108
+ return false;
109
+ try {
110
+ const [canonicalBase, canonicalRoot] = await Promise.all([realpath(base), realpath(repoRoot)]);
111
+ if (canonicalBase !== canonicalRoot || !(await stat(canonicalBase)).isDirectory())
112
+ return false;
113
+ // Metadata can resolve even when this directory cannot be read or traversed.
114
+ await access(canonicalBase, constants.R_OK | constants.X_OK);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
@@ -0,0 +1,9 @@
1
+ import { formatSummary } from "../cli/summary.js";
2
+ import { writeOutputFiles } from "./output-paths.js";
3
+ export async function saveLocalResults(folder, report, refusedFiles, outputDir) {
4
+ const [findingsPath, summaryPath] = await writeOutputFiles(folder, {
5
+ "findings.json": `${JSON.stringify({ ...report, refusedFiles }, null, 2)}\n`,
6
+ "summary.md": formatSummary(report),
7
+ }, outputDir);
8
+ return { findingsPath, summaryPath };
9
+ }