@counterfact-labs/cli 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/bundle/client-release.json +1 -1
- package/dist/adapters/anthropic.js +11 -2
- package/dist/adapters/completion.js +3 -0
- package/dist/adapters/durable-write.js +9 -2
- package/dist/adapters/files.js +25 -13
- package/dist/adapters/git-ignore.js +28 -14
- package/dist/adapters/git.js +1 -1
- package/dist/adapters/gitignore-output.js +41 -0
- package/dist/adapters/models.js +1 -6
- package/dist/adapters/output-paths.js +5 -2
- package/dist/adapters/public-instructions.js +1 -1
- package/dist/adapters/relay-plan.js +2 -5
- package/dist/adapters/release.js +2 -1
- package/dist/adapters/repository-files.js +164 -4
- package/dist/adapters/repository-root.js +18 -2
- package/dist/adapters/results.js +8 -4
- package/dist/adapters/settings.js +26 -1
- package/dist/adapters/snapshot.js +86 -15
- package/dist/analysis-core/analysis/finding-audience-switch.js +1 -0
- package/dist/analysis-core/analysis/schemas.js +1 -1
- package/dist/analysis-core/analysis/source-notes/types.js +1 -1
- package/dist/analysis-core/chunks/377MLK4M.js +21 -0
- package/dist/analysis-core/chunks/{D6AWHLRI.js → 3JWEFCTJ.js} +1 -1
- package/dist/analysis-core/chunks/{3227KXHF.js → 4H23CSUM.js} +1 -1
- package/dist/analysis-core/chunks/5DGPLXYB.js +1 -0
- package/dist/analysis-core/chunks/5TATKWLG.js +1 -0
- package/dist/analysis-core/chunks/{EH3QQ3T4.js → 6B2HILSY.js} +1 -1
- package/dist/analysis-core/chunks/6QY5BV5B.js +14 -0
- package/dist/analysis-core/chunks/6U2IR4XB.js +1 -0
- package/dist/analysis-core/chunks/A7WJJMRY.js +1 -0
- package/dist/analysis-core/chunks/C6X5GDG4.js +1 -0
- package/dist/analysis-core/chunks/DLPVYWHZ.js +1 -0
- package/dist/analysis-core/chunks/DZZBQDGO.js +1 -0
- package/dist/analysis-core/chunks/F3R4J3ND.js +1 -0
- package/dist/analysis-core/chunks/FOKDE64Y.js +1 -0
- package/dist/analysis-core/chunks/K2GNDX3W.js +1 -0
- package/dist/analysis-core/chunks/KTNA2KTN.js +7 -0
- package/dist/analysis-core/chunks/KWECQJ6X.js +1 -0
- package/dist/analysis-core/chunks/{SL27GMEN.js → M6HYSTQD.js} +1 -1
- package/dist/analysis-core/chunks/{ZAA44GPI.js → N7U5N42V.js} +1 -1
- package/dist/analysis-core/chunks/NIOS7U6C.js +41 -0
- package/dist/analysis-core/chunks/{QMIXCT62.js → OPMA32A6.js} +1 -1
- package/dist/analysis-core/chunks/PECX3OXB.js +1 -0
- package/dist/analysis-core/chunks/QFY6A3MS.js +66 -0
- package/dist/analysis-core/chunks/SKVEBOGA.js +1 -0
- package/dist/analysis-core/chunks/{T66VDFXF.js → STI4JE7L.js} +1 -1
- package/dist/analysis-core/chunks/SVMEWGD3.js +1 -0
- package/dist/analysis-core/chunks/X7WZAA7S.js +1 -0
- package/dist/analysis-core/chunks/YDBO7P4X.js +1 -0
- package/dist/analysis-core/chunks/YUY436SM.js +1 -0
- package/dist/analysis-core/chunks/ZTOVBAG7.js +1 -0
- package/dist/analysis-core/contracts/file-safety.js +1 -0
- package/dist/analysis-core/contracts/relay-analysis.js +1 -0
- package/dist/analysis-core/contracts/requests.js +1 -1
- package/dist/analysis-core/conventions/analysis-fit.js +1 -1
- package/dist/analysis-core/conventions/file-admission.js +1 -1
- package/dist/analysis-core/data/finding-types.js +1 -1
- package/dist/analysis-core/findings/sites.js +1 -0
- package/dist/analysis-core/github/repo-key.js +1 -1
- package/dist/analysis-core/import/outside-loader-sources.js +1 -1
- package/dist/analysis-core/index.js +3 -3
- package/dist/analysis-core/llm/output-limit.js +1 -1
- package/dist/analysis-core/llm/types.js +1 -0
- package/dist/analysis-core/requests/answer-formats.js +1 -1
- package/dist/analysis-core/requests/headline-request.js +1 -1
- package/dist/analysis-core/requests/local-plan.js +1 -1
- package/dist/analysis-core/requests/registry.js +1 -1
- package/dist/analysis-core/requests/template-version.js +1 -1
- package/dist/analysis-core/requests/tools.js +1 -1
- package/dist/analysis-core/settings.js +1 -1
- package/dist/analysis-core/stages/engine-entry.js +1 -0
- package/dist/analysis-core/stages/execution-ports.js +1 -0
- package/dist/bundle/seal/deserialise.js +9 -1
- package/dist/bundle/seal/engine.js +76 -0
- package/dist/bundle/seal/freeze.js +9 -0
- package/dist/bundle/seal/metadata.js +3 -0
- package/dist/bundle/seal/open-engine.js +26 -0
- package/dist/bundle/seal/open.js +2 -8
- package/dist/cli/commands/analyze.js +36 -4
- package/dist/cli/commands/finding.js +9 -3
- package/dist/cli/context.js +4 -1
- package/dist/cli/finding-display.js +39 -0
- package/dist/cli/finding-lookup.js +92 -0
- package/dist/cli/finding-settings.js +5 -0
- package/dist/cli/fix-requests.js +42 -0
- package/dist/cli/intake-payload.js +19 -11
- package/dist/cli/main.js +9 -4
- package/dist/cli/mcp.js +65 -18
- package/dist/cli/relay-check.js +46 -13
- package/dist/cli/relay-preparation.js +28 -5
- package/dist/cli/result-check.js +68 -0
- package/dist/cli/summary.js +10 -3
- package/dist/contract/agent-choice.js +15 -0
- package/dist/contract/agent-proposal.js +38 -0
- package/dist/contract/attestation.js +106 -0
- package/dist/contract/cli-refusals.js +3 -0
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/console.js +21 -16
- package/dist/contract/engine.js +44 -0
- package/dist/contract/findings.js +28 -0
- package/dist/contract/fix-requests.js +30 -0
- package/dist/contract/messages.js +6 -94
- package/dist/contract/public-report.js +1 -0
- package/dist/contract/runtime-environment.js +12 -1
- package/dist/core/analysis/run-to-end.js +7 -52
- package/dist/gatekeeper/attestation.js +1 -1
- package/dist/gatekeeper/channel-binding.js +15 -0
- package/dist/gatekeeper/cli-api.js +12 -2
- package/dist/gatekeeper/connection-exchange.js +16 -12
- package/dist/gatekeeper/model.js +2 -1
- package/dist/gatekeeper/production-attestation.js +23 -17
- package/dist/gatekeeper/trust/policy.js +4 -1
- package/dist/gatekeeper/trust/schema.js +4 -0
- package/dist/transport/kms.js +25 -0
- package/package.json +4 -3
- package/dist/adapters/quality.js +0 -6
- package/dist/adapters/report.js +0 -29
- package/dist/analysis-core/analysis/analysis-flags.js +0 -1
- package/dist/analysis-core/analysis/finding-quality/config.js +0 -1
- package/dist/analysis-core/analysis/finding-quality/policy.js +0 -1
- package/dist/analysis-core/analysis/headlines/attachment.js +0 -1
- package/dist/analysis-core/analysis/headlines/input.js +0 -1
- package/dist/analysis-core/chunks/3CKW5JIF.js +0 -1
- package/dist/analysis-core/chunks/6YCABS5T.js +0 -1
- package/dist/analysis-core/chunks/7OYIBWX3.js +0 -1
- package/dist/analysis-core/chunks/DRGDIIFA.js +0 -1
- package/dist/analysis-core/chunks/FTODBFQW.js +0 -1
- package/dist/analysis-core/chunks/HPIAEQHE.js +0 -37
- package/dist/analysis-core/chunks/IVDDV6CU.js +0 -14
- package/dist/analysis-core/chunks/JF4GYMOZ.js +0 -1
- package/dist/analysis-core/chunks/K5OTWBTT.js +0 -22
- package/dist/analysis-core/chunks/MXXNED6N.js +0 -7
- package/dist/analysis-core/chunks/NCWEOJVJ.js +0 -6
- package/dist/analysis-core/chunks/O652FQQH.js +0 -1
- package/dist/analysis-core/chunks/OFBJNHC6.js +0 -1
- package/dist/analysis-core/chunks/QV2EOS7U.js +0 -1
- package/dist/analysis-core/chunks/STZOC7LN.js +0 -1
- package/dist/analysis-core/chunks/T2SAP7OV.js +0 -60
- package/dist/analysis-core/chunks/T5TREZCQ.js +0 -1
- package/dist/analysis-core/chunks/TDNTU4TF.js +0 -1
- package/dist/analysis-core/chunks/U5HOEVPE.js +0 -1
- package/dist/analysis-core/chunks/UEWL3GMQ.js +0 -1
- package/dist/analysis-core/chunks/V7BFUTM7.js +0 -1
- package/dist/core/analysis/finding-quality/config.js +0 -4
- 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
|
-
|
|
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.
|
|
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 ??
|
|
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 !==
|
|
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
|
-
|
|
38
|
+
if (created)
|
|
39
|
+
await rm(temporary, { force: true });
|
|
33
40
|
}
|
|
34
41
|
}
|
package/dist/adapters/files.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
|
|
5
5
|
import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
|
|
6
6
|
import { fetchLocalSources } from "../analysis-core/index.js";
|
|
@@ -34,12 +34,15 @@ export async function readLocalFolder(settings, folder, admission = {}, options
|
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
36
|
const targets = new Map();
|
|
37
|
-
const snapshot =
|
|
37
|
+
const snapshot = Object.create(null);
|
|
38
|
+
const snapshotTargets = new Map();
|
|
38
39
|
let visited = 0;
|
|
39
40
|
const targetOf = async (path, purpose, directory = false) => {
|
|
40
41
|
const target = await realpath(join(root, path));
|
|
41
42
|
const result = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission, localTarget: { root, path: target, excludedTargets } });
|
|
42
43
|
if (!result.admitted) {
|
|
44
|
+
if (purpose === "proposal")
|
|
45
|
+
throw new FileReadRefusal(result.reason, path);
|
|
43
46
|
refused.push({ path, reason: result.reason });
|
|
44
47
|
if (result.reason === "secret" || result.reason === "out-of-scope")
|
|
45
48
|
warnings.push({ path, reason: result.reason });
|
|
@@ -50,6 +53,8 @@ export async function readLocalFolder(settings, folder, admission = {}, options
|
|
|
50
53
|
const allowed = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission,
|
|
51
54
|
gitIgnored, localTarget: { root, path: target, parent, excludedTargets } });
|
|
52
55
|
if (!allowed.admitted) {
|
|
56
|
+
if (purpose === "proposal")
|
|
57
|
+
throw new FileReadRefusal(allowed.reason, path);
|
|
53
58
|
if (directory)
|
|
54
59
|
gitSkipped.push(...await ignoredFiles(join(root, path), path));
|
|
55
60
|
else
|
|
@@ -124,20 +129,27 @@ export async function readLocalFolder(settings, folder, admission = {}, options
|
|
|
124
129
|
const listing = new Set(entries.map(entry => entry.path));
|
|
125
130
|
const repoKey = localRepoKeyFor(root);
|
|
126
131
|
const repository = createRepositoryFiles(settings, root, admission, options);
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
});
|
|
137
148
|
const expanded = discovered.root !== undefined;
|
|
138
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) })));
|
|
139
150
|
const captured = { ...Object.fromEntries(Object.entries(snapshot).map(([path, bytes]) => [repoPathFrom(path, discovered.root), bytes])), ...repository.snapshot };
|
|
140
|
-
return { repoKey, files: discovered.files, analysisSize, ...(analysisFit ? { analysisFit } : {}), ...(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 } : {}),
|
|
141
153
|
...(options.captureSnapshot ? { snapshot: Object.fromEntries(Object.keys(discovered.files).map(path => [path, captured[path]])) } : {}), fragment: discovered.fragment, selection: discovered.selection,
|
|
142
154
|
skippedFiles: mergeSkippedFiles(discovered.skippedFiles, ignored, expanded ? refused.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : refused),
|
|
143
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
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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,
|
|
45
|
+
cache.set(path, null);
|
|
39
46
|
return;
|
|
40
47
|
}
|
|
41
48
|
throw error;
|
|
42
49
|
}
|
|
43
|
-
const
|
|
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.
|
|
56
|
+
cache.set(path, matches.get(path) ?? null);
|
|
46
57
|
});
|
|
47
|
-
return new
|
|
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
|
}
|
package/dist/adapters/git.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/adapters/models.js
CHANGED
|
@@ -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 = {}
|
|
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,
|
|
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,13 +1,10 @@
|
|
|
1
1
|
import { bundleContentHash } from "../bundle/seal/signature.js";
|
|
2
2
|
import { checkPlannedCallsAgainstBundle } from "../bundle/seal/planned-calls.js";
|
|
3
|
-
import {
|
|
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
|
|
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
10
|
bundleHash: bundleContentHash(bundle.sealed), relayVersion,
|
package/dist/adapters/release.js
CHANGED
|
@@ -7,6 +7,7 @@ import { relayOrigin } from "../gatekeeper/connection.js";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { cliRefusals } from "../contract/cli-refusals.js";
|
|
9
9
|
import { RELEASE_KEY_SPKI_SHA256 } from "../contract/release-key.js";
|
|
10
|
+
import { findingAudienceSettings } from "../analysis-core/analysis/finding-audience-switch.js";
|
|
10
11
|
const releasePins = z.object({ verificationKey: z.string().min(1), relayVersion: z.string().min(1), localVersion: z.string().min(1),
|
|
11
12
|
relayUrl: z.string().min(1), projectContext: z.literal("checks") });
|
|
12
13
|
export function readClientReleasePins(value) {
|
|
@@ -20,7 +21,7 @@ export function validateClientRelease(release, settings = {}) {
|
|
|
20
21
|
const tools = loadPublicRequestTools(bundle, release.verificationKey, settings);
|
|
21
22
|
if (!bundle.relayVersions.includes(release.relayVersion))
|
|
22
23
|
throw new Error();
|
|
23
|
-
return { bundle, tools };
|
|
24
|
+
return { bundle, tools, settings: findingAudienceSettings(settings, bundle) };
|
|
24
25
|
}
|
|
25
26
|
export function readClientReleaseKey(pem) {
|
|
26
27
|
const key = createPublicKey(pem);
|
|
@@ -1,11 +1,172 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
4
5
|
import { READ_LIMITS } from "../analysis-core/import/outside-loader-sources.js";
|
|
5
6
|
import { admitFile, FileReadRefusal, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
|
|
6
7
|
import { createGitIgnore } from "./git-ignore.js";
|
|
7
8
|
import { createIgnoredFileCollector } from "./ignored-files.js";
|
|
8
|
-
import { searchBaseMatches, searchBoundary } from "./repository-root.js";
|
|
9
|
+
import { readTargetMatches, searchBaseMatches, searchBoundary } from "./repository-root.js";
|
|
10
|
+
import { resolveLocalExclusions as safetyExclusions } from "../analysis-core/contracts/file-safety.js";
|
|
11
|
+
/** The transport lists permitted bytes without deciding their role in an agent. */
|
|
12
|
+
export async function createAnalysisFileSource(settings, root, admission, signal, options = {}) {
|
|
13
|
+
const guarded = { ...settings, CF_LOCAL_SYMLINK_GUARD: "1" };
|
|
14
|
+
const expectedBoundary = (await searchBoundary(root))?.base ?? null;
|
|
15
|
+
const excludedTargets = await safetyExclusions(admission, async (path) => realpath(join(root, path)).catch(() => null));
|
|
16
|
+
const committed = options.committed && new Set([...options.committed].flatMap(path => {
|
|
17
|
+
const parts = path.split("/");
|
|
18
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
|
19
|
+
}));
|
|
20
|
+
const permission = async (path) => {
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
if (committed && !committed.has(path))
|
|
23
|
+
return { admitted: false, reason: "out-of-scope" };
|
|
24
|
+
const lexical = admitFile(guarded, { path }, { ...admission, purpose: "listing", route: "local-folder" });
|
|
25
|
+
if (!lexical.admitted)
|
|
26
|
+
return lexical;
|
|
27
|
+
const target = await realpath(join(root, path));
|
|
28
|
+
if (relative(root, target).split(sep).includes(".git"))
|
|
29
|
+
return { admitted: false, reason: "out-of-scope" };
|
|
30
|
+
const currentExclusions = await safetyExclusions(admission, async (path) => realpath(join(root, path)).catch(() => null));
|
|
31
|
+
const canonical = relative(root, target).split(sep).join("/");
|
|
32
|
+
if (committed && !committed.has(canonical))
|
|
33
|
+
return { admitted: false, reason: "out-of-scope" };
|
|
34
|
+
const ask = { ...admission, purpose: "listing", route: "local-folder", localTarget: { root, path: target, excludedTargets: [...excludedTargets, ...currentExclusions] } };
|
|
35
|
+
const lexicalTarget = admitFile(guarded, { path }, ask);
|
|
36
|
+
const allowed = lexicalTarget.admitted ? admitFile(guarded, { path: canonical }, ask) : lexicalTarget;
|
|
37
|
+
return allowed.admitted ? { admitted: true, target } : allowed;
|
|
38
|
+
};
|
|
39
|
+
const admittedCapture = async (file) => {
|
|
40
|
+
try {
|
|
41
|
+
const current = await permission(file.path);
|
|
42
|
+
if (!current.admitted)
|
|
43
|
+
return current;
|
|
44
|
+
if (current.target !== file.target || !await readTargetMatches(file.target, expectedBoundary))
|
|
45
|
+
return { admitted: false, reason: "out-of-scope" };
|
|
46
|
+
const info = await stat(current.target, { bigint: true });
|
|
47
|
+
return info.isFile() && info.dev === file.device && info.ino === file.inode
|
|
48
|
+
? { admitted: true } : { admitted: false, reason: "out-of-scope" };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
return { admitted: false, reason: "unreadable" };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
allowed: admittedCapture,
|
|
57
|
+
async capture(accept, existing = new Map()) {
|
|
58
|
+
let entriesVisited = 0, truncated = false;
|
|
59
|
+
const maxBytes = options.maxBytes ?? READ_LIMITS.archive.bytes;
|
|
60
|
+
const directories = new Set();
|
|
61
|
+
const targets = new Set();
|
|
62
|
+
async function walk(dir, prefix) {
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
if (directories.has(dir))
|
|
65
|
+
return;
|
|
66
|
+
directories.add(dir);
|
|
67
|
+
const handle = await opendir(dir);
|
|
68
|
+
try {
|
|
69
|
+
while (true) {
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
if (entriesVisited >= READ_LIMITS.archive.files) {
|
|
72
|
+
truncated = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const item = await handle.read();
|
|
76
|
+
if (!item)
|
|
77
|
+
return;
|
|
78
|
+
entriesVisited++;
|
|
79
|
+
// Administrative markers are local boundary evidence, never analysis inputs.
|
|
80
|
+
if (item.name === ".git")
|
|
81
|
+
continue;
|
|
82
|
+
const path = prefix ? `${prefix}/${item.name}` : item.name;
|
|
83
|
+
let allowed;
|
|
84
|
+
try {
|
|
85
|
+
allowed = await permission(path);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!allowed.admitted)
|
|
92
|
+
continue;
|
|
93
|
+
const target = allowed.target;
|
|
94
|
+
const info = await stat(target, { bigint: true }).catch(() => null);
|
|
95
|
+
if (!info)
|
|
96
|
+
continue;
|
|
97
|
+
if (info.isDirectory()) {
|
|
98
|
+
if (await lstat(join(target, ".git")).then(() => true, error => error.code !== "ENOENT"))
|
|
99
|
+
continue;
|
|
100
|
+
await walk(target, relative(root, target).split(sep).join("/"));
|
|
101
|
+
if (entriesVisited >= READ_LIMITS.archive.files)
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
else if (info.isFile() && !targets.has(target)) {
|
|
105
|
+
const canonical = relative(root, target).split(sep).join("/");
|
|
106
|
+
const prior = existing.get(target);
|
|
107
|
+
if (prior) {
|
|
108
|
+
const current = { ...prior, path: canonical };
|
|
109
|
+
if (prior.device === info.dev && prior.inode === info.ino && (await admittedCapture(current)).admitted)
|
|
110
|
+
accept(current);
|
|
111
|
+
targets.add(target);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (info.size > BigInt(maxBytes)) {
|
|
115
|
+
truncated = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let input, bytes;
|
|
119
|
+
try {
|
|
120
|
+
input = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
121
|
+
const before = await input.stat({ bigint: true });
|
|
122
|
+
if (!before.isFile() || before.dev !== info.dev || before.ino !== info.ino || before.size !== info.size)
|
|
123
|
+
continue;
|
|
124
|
+
bytes = Buffer.alloc(65_536);
|
|
125
|
+
const hash = createHash("sha256");
|
|
126
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
127
|
+
let offset = 0;
|
|
128
|
+
while (offset < Number(before.size)) {
|
|
129
|
+
signal?.throwIfAborted();
|
|
130
|
+
const { bytesRead } = await input.read(bytes, 0, Math.min(bytes.length, Number(before.size) - offset), offset);
|
|
131
|
+
if (!bytesRead)
|
|
132
|
+
break;
|
|
133
|
+
const part = bytes.subarray(0, bytesRead);
|
|
134
|
+
hash.update(part);
|
|
135
|
+
decoder.decode(part, { stream: true });
|
|
136
|
+
offset += bytesRead;
|
|
137
|
+
}
|
|
138
|
+
decoder.decode();
|
|
139
|
+
const after = await input.stat({ bigint: true });
|
|
140
|
+
if (offset !== Number(before.size) || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
|
|
141
|
+
continue;
|
|
142
|
+
const file = { path: canonical, target, size: offset, hash: hash.digest("hex"), device: before.dev, inode: before.ino };
|
|
143
|
+
await options.verify?.(file);
|
|
144
|
+
// Git verification can yield while local permissions or checkout boundaries change.
|
|
145
|
+
if (!(await admittedCapture(file)).admitted)
|
|
146
|
+
continue;
|
|
147
|
+
targets.add(target);
|
|
148
|
+
accept(file);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
signal?.throwIfAborted();
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
// Clear source bytes before the buffer's memory is reused; decoded strings are left to garbage collection.
|
|
155
|
+
bytes?.fill(0);
|
|
156
|
+
await input?.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await handle.close().catch(() => { });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await walk(root, "");
|
|
166
|
+
return { entriesVisited, truncated };
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
9
170
|
/** Construction does no I/O. Discovery is initialized only by the owner's request. */
|
|
10
171
|
export function createRepositoryFiles(settings, folder, admission = {}, options = {}) {
|
|
11
172
|
const guarded = { ...settings, CF_LOCAL_SYMLINK_GUARD: "1" };
|
|
@@ -162,8 +323,7 @@ export function createRepositoryFiles(settings, folder, admission = {}, options
|
|
|
162
323
|
const allowed = await admit(ctx, entry.path, target, entry.size);
|
|
163
324
|
if (!allowed.admitted)
|
|
164
325
|
return { text: null, bytesRead, refusal: allowed.reason };
|
|
165
|
-
|
|
166
|
-
if (!boundary || !await searchBaseMatches(boundary.base, ctx.base))
|
|
326
|
+
if (!await readTargetMatches(target, ctx.base))
|
|
167
327
|
return { text: null, bytesRead, refusal: "out-of-scope" };
|
|
168
328
|
const capacity = Math.min(maxBytes, READ_LIMITS.codeScan.fileBytes + 1);
|
|
169
329
|
if (!Number.isSafeInteger(capacity) || capacity <= 0)
|