@counterfact-labs/cli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +7 -1
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/anthropic.js +11 -2
  4. package/dist/adapters/completion.js +3 -0
  5. package/dist/adapters/durable-write.js +9 -2
  6. package/dist/adapters/files.js +29 -13
  7. package/dist/adapters/git-ignore.js +28 -14
  8. package/dist/adapters/git.js +1 -1
  9. package/dist/adapters/gitignore-output.js +41 -0
  10. package/dist/adapters/models.js +1 -6
  11. package/dist/adapters/output-paths.js +5 -2
  12. package/dist/adapters/public-instructions.js +1 -1
  13. package/dist/adapters/relay-plan.js +4 -7
  14. package/dist/adapters/release.js +10 -4
  15. package/dist/adapters/repository-files.js +164 -4
  16. package/dist/adapters/repository-root.js +18 -2
  17. package/dist/adapters/results.js +8 -4
  18. package/dist/adapters/settings.js +27 -1
  19. package/dist/adapters/snapshot.js +86 -15
  20. package/dist/analysis-core/analysis/finding-audience-switch.js +1 -0
  21. package/dist/analysis-core/analysis/schemas.js +1 -1
  22. package/dist/analysis-core/analysis/source-notes/types.js +1 -1
  23. package/dist/analysis-core/chunks/377MLK4M.js +21 -0
  24. package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
  25. package/dist/analysis-core/chunks/{Y5AUKKYG.js → 3JWEFCTJ.js} +1 -1
  26. package/dist/analysis-core/chunks/{3227KXHF.js → 4H23CSUM.js} +1 -1
  27. package/dist/analysis-core/chunks/5DGPLXYB.js +1 -0
  28. package/dist/analysis-core/chunks/5TATKWLG.js +1 -0
  29. package/dist/analysis-core/chunks/{EH3QQ3T4.js → 6B2HILSY.js} +1 -1
  30. package/dist/analysis-core/chunks/6QY5BV5B.js +14 -0
  31. package/dist/analysis-core/chunks/6U2IR4XB.js +1 -0
  32. package/dist/analysis-core/chunks/A7WJJMRY.js +1 -0
  33. package/dist/analysis-core/chunks/C6X5GDG4.js +1 -0
  34. package/dist/analysis-core/chunks/DLPVYWHZ.js +1 -0
  35. package/dist/analysis-core/chunks/DZZBQDGO.js +1 -0
  36. package/dist/analysis-core/chunks/F3R4J3ND.js +1 -0
  37. package/dist/analysis-core/chunks/FOKDE64Y.js +1 -0
  38. package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
  39. package/dist/analysis-core/chunks/K2GNDX3W.js +1 -0
  40. package/dist/analysis-core/chunks/KTNA2KTN.js +7 -0
  41. package/dist/analysis-core/chunks/KWECQJ6X.js +1 -0
  42. package/dist/analysis-core/chunks/{SL27GMEN.js → M6HYSTQD.js} +1 -1
  43. package/dist/analysis-core/chunks/{VFENG4MR.js → MJGB25S5.js} +1 -1
  44. package/dist/analysis-core/chunks/{ZAA44GPI.js → N7U5N42V.js} +1 -1
  45. package/dist/analysis-core/chunks/NIOS7U6C.js +41 -0
  46. package/dist/analysis-core/chunks/{QPXNXMLC.js → OPMA32A6.js} +1 -1
  47. package/dist/analysis-core/chunks/PECX3OXB.js +1 -0
  48. package/dist/analysis-core/chunks/QFY6A3MS.js +66 -0
  49. package/dist/analysis-core/chunks/SKVEBOGA.js +1 -0
  50. package/dist/analysis-core/chunks/{VER355VN.js → STI4JE7L.js} +1 -1
  51. package/dist/analysis-core/chunks/SVMEWGD3.js +1 -0
  52. package/dist/analysis-core/chunks/X7WZAA7S.js +1 -0
  53. package/dist/analysis-core/chunks/YDBO7P4X.js +1 -0
  54. package/dist/analysis-core/chunks/YUY436SM.js +1 -0
  55. package/dist/analysis-core/chunks/ZTOVBAG7.js +1 -0
  56. package/dist/analysis-core/contracts/file-safety.js +1 -0
  57. package/dist/analysis-core/contracts/relay-analysis.js +1 -0
  58. package/dist/analysis-core/contracts/requests.js +1 -1
  59. package/dist/analysis-core/conventions/analysis-fit.js +1 -0
  60. package/dist/analysis-core/conventions/file-admission.js +1 -1
  61. package/dist/analysis-core/data/finding-types.js +1 -1
  62. package/dist/analysis-core/errors.js +1 -1
  63. package/dist/analysis-core/findings/sites.js +1 -0
  64. package/dist/analysis-core/github/repo-key.js +1 -1
  65. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  66. package/dist/analysis-core/index.js +3 -3
  67. package/dist/analysis-core/llm/cache-policy.js +1 -1
  68. package/dist/analysis-core/llm/cost.js +1 -1
  69. package/dist/analysis-core/llm/output-limit.js +1 -1
  70. package/dist/analysis-core/llm/types.js +1 -0
  71. package/dist/analysis-core/requests/answer-formats.js +1 -1
  72. package/dist/analysis-core/requests/headline-request.js +1 -1
  73. package/dist/analysis-core/requests/local-plan.js +1 -1
  74. package/dist/analysis-core/requests/registry.js +1 -1
  75. package/dist/analysis-core/requests/template-version.js +1 -1
  76. package/dist/analysis-core/requests/tools.js +1 -1
  77. package/dist/analysis-core/settings.js +1 -1
  78. package/dist/analysis-core/stages/engine-entry.js +1 -0
  79. package/dist/analysis-core/stages/execution-ports.js +1 -0
  80. package/dist/bundle/seal/deserialise.js +9 -1
  81. package/dist/bundle/seal/engine.js +76 -0
  82. package/dist/bundle/seal/freeze.js +9 -0
  83. package/dist/bundle/seal/metadata.js +3 -0
  84. package/dist/bundle/seal/open-engine.js +26 -0
  85. package/dist/bundle/seal/open.js +2 -8
  86. package/dist/bundle/seal/signature.js +2 -2
  87. package/dist/cli/commands/analyze.js +39 -5
  88. package/dist/cli/commands/finding.js +9 -3
  89. package/dist/cli/context.js +4 -1
  90. package/dist/cli/development-relay.js +34 -0
  91. package/dist/cli/finding-display.js +39 -0
  92. package/dist/cli/finding-lookup.js +92 -0
  93. package/dist/cli/finding-settings.js +5 -0
  94. package/dist/cli/fix-requests.js +42 -0
  95. package/dist/cli/intake-payload.js +19 -11
  96. package/dist/cli/main.js +9 -4
  97. package/dist/cli/mcp.js +65 -18
  98. package/dist/cli/relay-check.js +47 -14
  99. package/dist/cli/relay-preparation.js +31 -6
  100. package/dist/cli/result-check.js +68 -0
  101. package/dist/cli/summary.js +10 -3
  102. package/dist/contract/agent-choice.js +15 -0
  103. package/dist/contract/agent-proposal.js +38 -0
  104. package/dist/contract/attestation.js +106 -0
  105. package/dist/contract/cli-refusals.js +5 -0
  106. package/dist/contract/cli-version.js +1 -1
  107. package/dist/contract/console.js +41 -16
  108. package/dist/contract/engine.js +44 -0
  109. package/dist/contract/findings.js +28 -0
  110. package/dist/contract/fix-requests.js +30 -0
  111. package/dist/contract/messages.js +6 -94
  112. package/dist/contract/public-report.js +1 -0
  113. package/dist/contract/runtime-environment.js +12 -1
  114. package/dist/core/analysis/run-to-end.js +7 -52
  115. package/dist/gatekeeper/attestation.js +1 -1
  116. package/dist/gatekeeper/channel-binding.js +15 -0
  117. package/dist/gatekeeper/cli-api.js +12 -2
  118. package/dist/gatekeeper/connection-exchange.js +18 -13
  119. package/dist/gatekeeper/console.js +11 -4
  120. package/dist/gatekeeper/model.js +2 -1
  121. package/dist/gatekeeper/production-attestation.js +23 -17
  122. package/dist/gatekeeper/trust/policy.js +4 -1
  123. package/dist/gatekeeper/trust/schema.js +4 -0
  124. package/dist/transport/kms.js +25 -0
  125. package/package.json +4 -3
  126. package/dist/adapters/quality.js +0 -6
  127. package/dist/adapters/report.js +0 -29
  128. package/dist/analysis-core/analysis/analysis-flags.js +0 -1
  129. package/dist/analysis-core/analysis/finding-quality/config.js +0 -1
  130. package/dist/analysis-core/analysis/finding-quality/policy.js +0 -1
  131. package/dist/analysis-core/analysis/headlines/attachment.js +0 -1
  132. package/dist/analysis-core/analysis/headlines/input.js +0 -1
  133. package/dist/analysis-core/chunks/6YCABS5T.js +0 -1
  134. package/dist/analysis-core/chunks/7QDOHWSY.js +0 -27
  135. package/dist/analysis-core/chunks/BZNHHLNP.js +0 -1
  136. package/dist/analysis-core/chunks/DTMHXKP6.js +0 -7
  137. package/dist/analysis-core/chunks/EQTIRIMU.js +0 -37
  138. package/dist/analysis-core/chunks/G322LST6.js +0 -1
  139. package/dist/analysis-core/chunks/IL2KRP3T.js +0 -1
  140. package/dist/analysis-core/chunks/JF4GYMOZ.js +0 -1
  141. package/dist/analysis-core/chunks/KEKTZDIK.js +0 -1
  142. package/dist/analysis-core/chunks/M7MMAUAK.js +0 -1
  143. package/dist/analysis-core/chunks/OFBJNHC6.js +0 -1
  144. package/dist/analysis-core/chunks/ORCYIIHB.js +0 -60
  145. package/dist/analysis-core/chunks/PVPEY7NF.js +0 -1
  146. package/dist/analysis-core/chunks/STZOC7LN.js +0 -1
  147. package/dist/analysis-core/chunks/T5TREZCQ.js +0 -1
  148. package/dist/analysis-core/chunks/TDNTU4TF.js +0 -1
  149. package/dist/analysis-core/chunks/U5HOEVPE.js +0 -1
  150. package/dist/analysis-core/chunks/UEWL3GMQ.js +0 -1
  151. package/dist/analysis-core/chunks/YVDCJN7D.js +0 -14
  152. package/dist/analysis-core/chunks/ZMUBMYWN.js +0 -1
  153. package/dist/core/analysis/finding-quality/config.js +0 -4
  154. package/dist/core/analysis/structural-run.js +0 -16
package/README.md CHANGED
@@ -16,6 +16,7 @@ Use Node.js 22 or later and install the pinned dependencies:
16
16
 
17
17
  ```bash
18
18
  npm ci
19
+ npm run build:core
19
20
  ```
20
21
 
21
22
  Set `ANTHROPIC_API_KEY` in your shell to your Anthropic development key, then run these commands from this checkout:
@@ -141,8 +142,9 @@ or add both to the sandbox's allowed domains.
141
142
  The command uses the existing saved sign-in and sends `X-Counterfact-CLI-Version: 0.2.0`. It asks
142
143
  `/api/cli/checks/mode` before reading source or starting paid work. Upload, paused, update-required and unavailable
143
144
  responses stop the analysis; the command never falls back to uploading files. Settings are captured at command start.
144
- The analysis uses a committed Git folder. Admitted files must match that commit; unrelated local output files are ignored.
145
+ An analysis reads the agent's files as they are on disk, including uncommitted changes, and also works in folders without Git.
145
146
  The console owns prices, expressed in millionths of a dollar.
147
+ By default, the CLI adds .counterfact/ to the folder's .gitignore when Git doesn't already ignore it. Set CF_LOCAL_GITIGNORE_OUTPUT=0 to turn this off.
146
148
 
147
149
  The installed command uses the relay address in `bundle/client-release.json` and saved sign-in. The relay runner sends
148
150
  repository facts and the admitted file list (paths and sizes, without contents or absolute checkout paths) to
@@ -154,6 +156,10 @@ Release stays blocked while `relayUrl` and `projectContext` are unconfigured in
154
156
  `projectContext` to `checks` only after local start support is reviewed. The release validator requires both settings
155
157
  along with the signed bundle and trust pins; an environment override cannot bypass the project-context pin.
156
158
 
159
+ ## Relay privacy
160
+
161
+ The sealed relay's source is public at [github.com/counterfact-labs/sealed-relay](https://github.com/counterfact-labs/sealed-relay), with a reproducible build of its JavaScript, and the CLI checks the relay's attestation against a pinned image digest before sending it any analysis input.
162
+
157
163
  ## Build and release the customer CLI archive
158
164
 
159
165
  Generate `bundle/sealed.json` with the existing release tooling and externally supplied release keys. Install the
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "verificationKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbBab1PYdmBKXxCvoJ47toJhQvxPevo9nlZnb0zbDWoc=\n-----END PUBLIC KEY-----\n",
3
3
  "relayVersion": "relay-1.0.1",
4
- "localVersion": "0.2.3",
4
+ "localVersion": "0.2.5",
5
5
  "relayUrl": "https://relay.counterfactlabs.com:8443",
6
6
  "projectContext": "checks"
7
7
  }
@@ -5,6 +5,7 @@ import { estimateTokensFromBytes } from "../analysis-core/llm/cost.js";
5
5
  import { anthropicStructuredLimit, supportsReasoningControls } from "../analysis-core/llm/output-limit.js";
6
6
  import { requestCachesInput, structuredPromptOf } from "./prompt-cache.js";
7
7
  import { StructuredOutputError } from "../analysis-core/llm/structured-output-error.js";
8
+ import { THINKING_MODES } from "../analysis-core/llm/types.js";
8
9
  export function anthropicLlm(settings, opts = {}) {
9
10
  const client = opts.client ?? new Anthropic({ ...(opts.apiKey === undefined ? {} : { apiKey: opts.apiKey }) });
10
11
  return { structured: call => structuredCall(settings, client, call, opts.onUsage, opts.sourceRetries) };
@@ -19,6 +20,10 @@ export function anthropicCaptureBackend(client = new Anthropic({ maxRetries: 0 }
19
20
  function canceled() {
20
21
  return new JobError("canceled", "The model call was canceled.");
21
22
  }
23
+ export class ProviderRequestRefused extends Error {
24
+ name = "ProviderRequestRefused";
25
+ constructor() { super("Invalid model request"); }
26
+ }
22
27
  export class ProviderCallError extends JobError {
23
28
  payment;
24
29
  constructor(code, message, retryable, payment) {
@@ -34,6 +39,10 @@ function providerFailure(error, signal) {
34
39
  return new ProviderCallError("rate_limited", "The model provider is busy. Wait a minute and try again.", true, "unpaid");
35
40
  }
36
41
  if (error instanceof Anthropic.APIConnectionError) {
42
+ // The guard rejects before network access, so no provider work can have been paid.
43
+ if (error.cause instanceof ProviderRequestRefused) {
44
+ return new ProviderCallError("llm", "Invalid model request", false, "unpaid");
45
+ }
37
46
  return new ProviderCallError("llm", "The model could not be reached. Try again.", true, "unknown");
38
47
  }
39
48
  const directive = error instanceof Anthropic.APIError ? error.headers?.get("x-should-retry") : undefined;
@@ -66,13 +75,13 @@ function providerRequest(settings, call) {
66
75
  const streamed = supportsReasoningControls(call.model);
67
76
  const params = {
68
77
  model: call.model, max_tokens: anthropicStructuredLimit(call),
69
- ...(streamed ? { thinking: { type: call.thinking ?? "adaptive" } } : {}),
78
+ ...(streamed ? { thinking: { type: call.thinking ?? THINKING_MODES.adaptive } } : {}),
70
79
  ...(call.system ? { system: call.system } : {}),
71
80
  messages: [{ role: "user", content: structuredPromptOf(settings, call) }],
72
81
  output_config: {
73
82
  // SDK stream parsing can throw before returning usage for incomplete JSON.
74
83
  format: { type: format.type, schema: format.schema },
75
- ...(call.effort && streamed && call.thinking !== "disabled" ? { effort: call.effort } : {}),
84
+ ...(call.effort && streamed && call.thinking !== THINKING_MODES.disabled ? { effort: call.effort } : {}),
76
85
  },
77
86
  };
78
87
  return { params, format, streamed };
@@ -104,6 +104,9 @@ export async function loadCompletion(folder) {
104
104
  return null;
105
105
  throw new Error(INVALID_COMPLETION_MESSAGE);
106
106
  }
107
+ // Released folder-local records always had a Git commit; hashes alone cannot establish their provenance.
108
+ if (legacy.payload.agent.commit === null)
109
+ return null;
107
110
  if (!await matchesSnapshotProvenance(folder, legacy.payload.agent))
108
111
  throw new Error(INVALID_COMPLETION_MESSAGE);
109
112
  // Attempt imported recovery once automatically; uncertain legacy data must not block later analyses.
@@ -1,14 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { open, rename, rm } from "node:fs/promises";
3
+ import { chmod, mkdir, open, rename, rm } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
+ export async function preparePrivateDirectory(path) {
6
+ await mkdir(path, { recursive: true, mode: 0o700 });
7
+ await chmod(path, 0o700);
8
+ }
5
9
  export async function durableWrite(path, bytes) {
6
10
  const temporary = `${path}.${randomUUID()}.tmp`;
11
+ let created = false;
7
12
  const permission = process.permission !== undefined;
8
13
  // The permission model disables fsync, so sync data on each write and skip the unavailable directory sync.
9
14
  const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | (permission ? constants.O_DSYNC : 0);
10
15
  try {
11
16
  const file = await open(temporary, flags, 0o600);
17
+ created = true;
12
18
  try {
13
19
  await file.writeFile(bytes);
14
20
  if (!permission)
@@ -29,6 +35,7 @@ export async function durableWrite(path, bytes) {
29
35
  }
30
36
  }
31
37
  finally {
32
- await rm(temporary, { force: true });
38
+ if (created)
39
+ await rm(temporary, { force: true });
33
40
  }
34
41
  }
@@ -1,6 +1,7 @@
1
1
  import { opendir, realpath, stat, readFile } from "node:fs/promises";
2
2
  import { dirname, join, posix } from "node:path";
3
- import { admitFile, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
3
+ import { admitFile, FileReadRefusal, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
4
+ import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
4
5
  import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
5
6
  import { fetchLocalSources } from "../analysis-core/index.js";
6
7
  import { repoPathFrom } from "../analysis-core/import/outside-loader-sources.js";
@@ -33,12 +34,15 @@ export async function readLocalFolder(settings, folder, admission = {}, options
33
34
  }
34
35
  });
35
36
  const targets = new Map();
36
- const snapshot = {};
37
+ const snapshot = Object.create(null);
38
+ const snapshotTargets = new Map();
37
39
  let visited = 0;
38
40
  const targetOf = async (path, purpose, directory = false) => {
39
41
  const target = await realpath(join(root, path));
40
42
  const result = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission, localTarget: { root, path: target, excludedTargets } });
41
43
  if (!result.admitted) {
44
+ if (purpose === "proposal")
45
+ throw new FileReadRefusal(result.reason, path);
42
46
  refused.push({ path, reason: result.reason });
43
47
  if (result.reason === "secret" || result.reason === "out-of-scope")
44
48
  warnings.push({ path, reason: result.reason });
@@ -49,6 +53,8 @@ export async function readLocalFolder(settings, folder, admission = {}, options
49
53
  const allowed = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission,
50
54
  gitIgnored, localTarget: { root, path: target, parent, excludedTargets } });
51
55
  if (!allowed.admitted) {
56
+ if (purpose === "proposal")
57
+ throw new FileReadRefusal(allowed.reason, path);
52
58
  if (directory)
53
59
  gitSkipped.push(...await ignoredFiles(join(root, path), path));
54
60
  else
@@ -117,23 +123,33 @@ export async function readLocalFolder(settings, folder, admission = {}, options
117
123
  await walk(root, "", new Set());
118
124
  else
119
125
  gitSkipped.push(...await ignoredFiles(root, ""));
126
+ const analysisSize = measureAnalysisSize(settings, entries, admission);
127
+ // The service makes the final decision; local prediction never changes retained files.
128
+ const analysisFit = settings.CF_REFUSE_TOO_LARGE === "0" ? undefined : assessAnalysisSize(analysisSize);
120
129
  const listing = new Set(entries.map(entry => entry.path));
121
130
  const repoKey = localRepoKeyFor(root);
122
131
  const repository = createRepositoryFiles(settings, root, admission, options);
123
- const discovered = await fetchLocalSources(settings, { repoKey, entries, admission, repository, read: async (entry) => {
124
- const target = guarded ? await targetOf(entry.path, "override") : targets.get(entry.path);
125
- return target ? readRepoFile(settings, entry, async () => {
126
- if (!options.captureSnapshot)
127
- return readFile(target, "utf8");
128
- const bytes = snapshot[entry.path] ?? await readFile(target);
129
- snapshot[entry.path] = bytes;
130
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
131
- }, { ...admission, listing, route: "local-folder", localTarget: { root, path: target, excludedTargets } }) : null;
132
- } });
132
+ const read = async (entry, purpose) => {
133
+ const target = guarded || purpose === "proposal" ? await targetOf(entry.path, purpose) : targets.get(entry.path);
134
+ return target ? readRepoFile(settings, entry, async () => {
135
+ if (!options.captureSnapshot)
136
+ return readFile(target, "utf8");
137
+ const cached = purpose !== "proposal" || snapshotTargets.get(entry.path) === target ? snapshot[entry.path] : undefined;
138
+ const bytes = cached ?? await readFile(target);
139
+ snapshot[entry.path] = bytes;
140
+ snapshotTargets.set(entry.path, target);
141
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
142
+ }, { ...admission, listing, route: "local-folder", localTarget: { root, path: target, excludedTargets } }) : null;
143
+ };
144
+ const discovered = await fetchLocalSources(settings, { repoKey, entries, admission, repository,
145
+ proposal: options.proposal, proposalRefusals: [...gitSkipped, ...refused],
146
+ read: entry => read(entry, "override"), proposalRead: entry => read(entry, "proposal"),
147
+ });
133
148
  const expanded = discovered.root !== undefined;
134
149
  const ignored = mergeSkippedFiles(expanded ? gitSkipped.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : gitSkipped, repository.skippedFiles.map(file => ({ ...file, path: expanded ? file.path : posix.relative(repository.selectedRoot ?? "", file.path) })));
135
150
  const captured = { ...Object.fromEntries(Object.entries(snapshot).map(([path, bytes]) => [repoPathFrom(path, discovered.root), bytes])), ...repository.snapshot };
136
- return { repoKey, files: discovered.files, ...(expanded ? { root: discovered.root, sourceBase: repository.sourceBase } : {}),
151
+ return { repoKey, ...(discovered.agentChoice ? { agentChoice: discovered.agentChoice } : {}), files: discovered.files, analysisSize, ...(analysisFit ? { analysisFit } : {}), ...(expanded ? { root: discovered.root, sourceBase: repository.sourceBase } : {}),
152
+ ...(discovered.proposalCounts ? { proposalCounts: discovered.proposalCounts } : {}),
137
153
  ...(options.captureSnapshot ? { snapshot: Object.fromEntries(Object.keys(discovered.files).map(path => [path, captured[path]])) } : {}), fragment: discovered.fragment, selection: discovered.selection,
138
154
  skippedFiles: mergeSkippedFiles(discovered.skippedFiles, ignored, expanded ? refused.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : refused),
139
155
  warnings: [...new Set(mergeUnlistedFileWarnings([...warnings.map(file => fileRefusalWarning(repoPathFrom(file.path, discovered.root), file.reason)), ...discovered.warnings], ignored.length))] };
@@ -2,21 +2,28 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import { mapLimit } from "../analysis-core/map-limit.js";
3
3
  import { respectGitignore } from "../analysis-core/conventions/file-admission.js";
4
4
  import { runGit } from "./git.js";
5
- import { gitRepositoryRoot, searchBaseMatches, searchBoundary } from "./repository-root.js";
5
+ import { gitRepositoryRoot } from "./repository-root.js";
6
6
  /** Query path facts only; admission owns whether an ignored path can be read. */
7
7
  export function createGitIgnore(settings, folder) {
8
+ const query = createGitIgnoreQuery(folder);
9
+ return (paths) => respectGitignore(settings) ? query(paths) : Promise.resolve(undefined);
10
+ }
11
+ export function createGitIgnoreQuery(folder) {
12
+ const query = createGitIgnoreFactsQuery(folder);
13
+ return async (paths) => {
14
+ const facts = await query(paths);
15
+ return facts && new Set([...facts].filter(([, fact]) => fact === "ignored").map(([path]) => path));
16
+ };
17
+ }
18
+ /** Git supplies the last matching rule, including a deliberate negation. */
19
+ export function createGitIgnoreFactsQuery(folder) {
8
20
  const cache = new Map();
9
21
  let root;
10
22
  return async (paths) => {
11
- if (!respectGitignore(settings))
12
- return undefined;
13
23
  if (!paths.length)
14
- return new Set();
15
- const base = await (root ??= (async () => {
16
- const [boundary, reported] = await Promise.all([searchBoundary(folder),
17
- gitRepositoryRoot(async (args) => (await runGit(folder, args)).toString("utf8"))]);
18
- return boundary && await searchBaseMatches(boundary.base, reported) ? boundary.base : null;
19
- })());
24
+ return new Map();
25
+ // Git's root owns ignore rules even when discovery stops at a different marker.
26
+ const base = await (root ??= gitRepositoryRoot(async (args) => (await runGit(folder, args)).toString("utf8"), folder));
20
27
  if (!base)
21
28
  return undefined;
22
29
  const candidates = paths.filter(path => {
@@ -30,20 +37,27 @@ export function createGitIgnore(settings, folder) {
30
37
  const input = Buffer.from(batch.map(path => resolve(folder, path)).join("\0") + "\0");
31
38
  let output;
32
39
  try {
33
- output = await runGit(folder, ["check-ignore", "-z", "--stdin"], { userConfig: true, input, maxBuffer: input.length + 1024 });
40
+ output = await runGit(folder, ["check-ignore", "--verbose", "-z", "--stdin"], { userConfig: true, input, maxBuffer: input.length + 1024 * 1024 });
34
41
  }
35
42
  catch (error) {
36
43
  if (error.code === 1) {
37
44
  for (const path of batch)
38
- cache.set(path, false);
45
+ cache.set(path, null);
39
46
  return;
40
47
  }
41
48
  throw error;
42
49
  }
43
- const matches = new Set(output.toString("utf8").split("\0"));
50
+ const fields = output.toString("utf8").split("\0");
51
+ const matches = new Map();
52
+ for (let index = 0; index + 3 < fields.length; index += 4) {
53
+ matches.set(fields[index + 3], fields[index + 2].startsWith("!") ? "included" : "ignored");
54
+ }
44
55
  for (const path of batch)
45
- cache.set(path, matches.has(path));
56
+ cache.set(path, matches.get(path) ?? null);
46
57
  });
47
- return new Set(candidates.filter(path => cache.get(resolve(folder, path))));
58
+ return new Map(candidates.flatMap(path => {
59
+ const fact = cache.get(resolve(folder, path));
60
+ return fact ? [[path, fact]] : [];
61
+ }));
48
62
  };
49
63
  }
@@ -8,7 +8,7 @@ export async function runGit(folder, args, options = {}) {
8
8
  "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "core.sshCommand=",
9
9
  "-c", "protocol.ext.allow=never", "-c", "diff.external=", "-c", "core.attributesFile=/dev/null",
10
10
  ...overrides, "-C", folder, ...command], {
11
- encoding: "buffer", timeout: options.timeout ?? 3000, maxBuffer,
11
+ encoding: "buffer", timeout: options.timeout ?? 3000, maxBuffer, signal: options.signal,
12
12
  env: { ...env, GIT_ATTR_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1",
13
13
  GIT_CONFIG_GLOBAL: options.userConfig ? process.env.GIT_CONFIG_GLOBAL : "/dev/null", GIT_PAGER: "cat" },
14
14
  });
@@ -0,0 +1,41 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { createGitIgnoreFactsQuery } from "./git-ignore.js";
4
+ import { runGit } from "./git.js";
5
+ import { analysisReadRoots, gitRepositoryRoot } from "./repository-root.js";
6
+ import { DEFAULT_OUTPUT_DIR, outputPaths } from "./output-paths.js";
7
+ const ignoreRule = `${DEFAULT_OUTPUT_DIR}/`;
8
+ export const GITIGNORE_OUTPUT_NOTICE = `Added ${ignoreRule} to .gitignore, so the local report stays out of Git.\n`;
9
+ export const gitignoreOutputEnabled = (settings = process.env) => settings.CF_LOCAL_GITIGNORE_OUTPUT !== "0";
10
+ /** Keep local excerpts out of Git after a successful default report write. */
11
+ export async function ignoreDefaultOutput(folder, outputDir, options = {}) {
12
+ if (outputDir !== undefined || !gitignoreOutputEnabled(options.settings))
13
+ return false;
14
+ try {
15
+ // Automatic edits need confirmed checkout boundaries even when ignore reads can trust Git alone.
16
+ const reported = await gitRepositoryRoot(async (args) => (await runGit(folder, args)).toString("utf8"), folder);
17
+ if (!(await analysisReadRoots(folder, reported)).repository)
18
+ return false;
19
+ const facts = await createGitIgnoreFactsQuery(folder)([ignoreRule]);
20
+ if (!facts || facts.has(ignoreRule))
21
+ return false;
22
+ // Appending preserves existing rules, and refusing links keeps this write in the selected folder.
23
+ const [path] = await outputPaths(folder, [".gitignore"], folder);
24
+ const file = await open(path, constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o644);
25
+ try {
26
+ const info = await file.stat();
27
+ if (!info.isFile())
28
+ return false;
29
+ const last = Buffer.alloc(1);
30
+ if (info.size)
31
+ await file.read(last, 0, 1, info.size - 1);
32
+ await file.writeFile(`${info.size && last[0] !== 10 ? "\n" : ""}${ignoreRule}\n`);
33
+ }
34
+ finally {
35
+ await file.close();
36
+ }
37
+ return true;
38
+ }
39
+ catch { /* Report delivery must survive an unavailable Git command or ignore file. */ }
40
+ return false;
41
+ }
@@ -1,6 +1,4 @@
1
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
2
  import { retryCalls } from "../analysis-core/llm/slots.js";
5
3
  import { relayModel, ModelConfigurationError } from "../gatekeeper/model.js";
6
4
  import { readableRequestTools, readableInstructions } from "./instructions.js";
@@ -42,10 +40,7 @@ export async function selectAnalysisExecution(transport, source, relay) {
42
40
  return { model: relayModel({ ...relay, bundleVersion: requestTools.bundleVersion }), requestTools,
43
41
  instructions: async () => { throw new Error("Readable instructions are unavailable in relay mode"); } };
44
42
  }
45
- export async function analysisExecution(model, options = {}, settings = {}) {
43
+ export async function analysisExecution(model, options = {}) {
46
44
  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
45
  return execution;
51
46
  }
@@ -1,8 +1,9 @@
1
- import { lstat, mkdir, realpath } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
3
  import { FINISH_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
4
4
  import { resolveFolderPath } from "./folder-path.js";
5
5
  import { durableWrite } from "./durable-write.js";
6
+ export const DEFAULT_OUTPUT_DIR = ".counterfact";
6
7
  export class OutputPathError extends Error {
7
8
  constructor() {
8
9
  super(FINISH_FAILURE_MESSAGE);
@@ -16,7 +17,7 @@ const within = (base, path) => {
16
17
  /** Validate the selected anchor and every output component before creating or replacing files. */
17
18
  export async function outputPaths(folder, names, outputDir, create = false) {
18
19
  const selected = resolve(folder), root = await resolveFolderPath(folder);
19
- const requested = resolve(outputDir ?? join(selected, ".counterfact"));
20
+ const requested = resolve(outputDir ?? join(selected, DEFAULT_OUTPUT_DIR));
20
21
  let directory = within(selected, requested) ? resolve(root, relative(selected, requested)) : requested;
21
22
  const external = !within(root, directory);
22
23
  const userUid = process.getuid?.();
@@ -84,6 +85,8 @@ export async function outputPaths(folder, names, outputDir, create = false) {
84
85
  export async function writeOutputFiles(folder, files, outputDir) {
85
86
  const names = Object.keys(files);
86
87
  const paths = await outputPaths(folder, names, outputDir, true);
88
+ if (paths.length)
89
+ await chmod(dirname(paths[0]), 0o700);
87
90
  for (let index = 0; index < paths.length; index++) {
88
91
  await outputPaths(folder, names, outputDir);
89
92
  await durableWrite(paths[index], files[names[index]]);
@@ -13,7 +13,7 @@ export function loadPublicRequestTools(value, verificationKey, settings = {}) {
13
13
  const renderingUnavailable = () => { throw new Error("Instruction rendering requires the sealed relay"); };
14
14
  if (findingHeadlinesEnabled(settings) && bundle.metadata.headlinePlan?.resultVersion !== HEADLINE_RESULT_VERSION)
15
15
  renderingUnavailable();
16
- return { ...versions, bundleVersion: bundle.version, modelReaderPrompt: renderingUnavailable, callInputBytes, checkDiagnosticsText,
16
+ return { ...versions, bundleVersion: bundle.version, answerFormats: bundle.answerFormats, modelReaderPrompt: renderingUnavailable, callInputBytes, checkDiagnosticsText,
17
17
  buildAnalysisCall: renderingUnavailable,
18
18
  headlinePlan(request) {
19
19
  const plan = bundle.metadata.headlinePlan;
@@ -1,15 +1,12 @@
1
- import { sealedBundleHash } from "../bundle/seal/signature.js";
1
+ import { bundleContentHash } from "../bundle/seal/signature.js";
2
2
  import { checkPlannedCallsAgainstBundle } from "../bundle/seal/planned-calls.js";
3
- import { localCallPlan } from "../analysis-core/requests/local-plan.js";
3
+ import { localSourcePlan } from "../analysis-core/requests/local-plan.js";
4
4
  import { relayPlanSchema } from "../contract/console.js";
5
5
  /** Callers authenticate the release before reading files; only this owner turns source facts into a paid plan. */
6
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
- });
7
+ const { files, calls } = localSourcePlan(settings, source);
11
8
  checkPlannedCallsAgainstBundle(bundle, calls);
12
9
  return { files, plan: relayPlanSchema.parse({ bundleVersion: tools.bundleVersion,
13
- bundleHash: sealedBundleHash(bundle), relayVersion,
10
+ bundleHash: bundleContentHash(bundle.sealed), relayVersion,
14
11
  calls: calls.map(({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens }) => ({ routeId, model, calls, maxInputBytes, maxInputTokens, maxOutputTokens })) }) };
15
12
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash, createPublicKey } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { sealedBundle } from "../contract/messages.js";
4
5
  import { loadPublicRequestTools } from "./public-instructions.js";
@@ -6,6 +7,7 @@ import { relayOrigin } from "../gatekeeper/connection.js";
6
7
  import { z } from "zod";
7
8
  import { cliRefusals } from "../contract/cli-refusals.js";
8
9
  import { RELEASE_KEY_SPKI_SHA256 } from "../contract/release-key.js";
10
+ import { findingAudienceSettings } from "../analysis-core/analysis/finding-audience-switch.js";
9
11
  const releasePins = z.object({ verificationKey: z.string().min(1), relayVersion: z.string().min(1), localVersion: z.string().min(1),
10
12
  relayUrl: z.string().min(1), projectContext: z.literal("checks") });
11
13
  export function readClientReleasePins(value) {
@@ -19,7 +21,7 @@ export function validateClientRelease(release, settings = {}) {
19
21
  const tools = loadPublicRequestTools(bundle, release.verificationKey, settings);
20
22
  if (!bundle.relayVersions.includes(release.relayVersion))
21
23
  throw new Error();
22
- return { bundle, tools };
24
+ return { bundle, tools, settings: findingAudienceSettings(settings, bundle) };
23
25
  }
24
26
  export function readClientReleaseKey(pem) {
25
27
  const key = createPublicKey(pem);
@@ -28,6 +30,10 @@ export function readClientReleaseKey(pem) {
28
30
  throw new Error(cliRefusals.release_unavailable);
29
31
  return key;
30
32
  }
33
+ export function installedReleasePaths() {
34
+ const base = new URL(import.meta.url.endsWith(".ts") ? "../bundle/" : "../../bundle/", import.meta.url);
35
+ return { bundle: fileURLToPath(new URL("sealed.json", base)), metadata: fileURLToPath(new URL("client-release.json", base)) };
36
+ }
31
37
  /** Installed releases always use the separately reviewed signing key. */
32
38
  export function loadInstalledRelease(paths) {
33
39
  return loadRelease(paths, false);
@@ -38,14 +44,14 @@ export function loadDevelopmentRelease(paths) {
38
44
  }
39
45
  async function loadRelease(paths, development) {
40
46
  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"));
47
+ const location = paths ?? installedReleasePaths();
48
+ const metadata = JSON.parse(await readFile(location.metadata, "utf8"));
43
49
  const pins = paths ? metadata : readClientReleasePins(metadata);
44
50
  if (typeof pins.verificationKey !== "string" || typeof pins.relayVersion !== "string" || !pins.relayVersion ||
45
51
  typeof pins.localVersion !== "string" || !pins.localVersion)
46
52
  throw new Error();
47
53
  const verificationKey = development ? createPublicKey(pins.verificationKey) : readClientReleaseKey(pins.verificationKey);
48
- const bundle = JSON.parse(await readFile(paths?.bundle ?? new URL("sealed.json", base), "utf8"));
54
+ const bundle = JSON.parse(await readFile(location.bundle, "utf8"));
49
55
  const release = { bundle, verificationKey, relayVersion: pins.relayVersion, localVersion: pins.localVersion,
50
56
  relayUrl: pins.relayUrl, projectContext: pins.projectContext };
51
57
  validateClientRelease(release);