engineering-behavior-observatory 0.2.1
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/LICENSE +202 -0
- package/README.md +151 -0
- package/contracts/codex-app-server-0.150.1/manifest.json +25 -0
- package/contracts/codex-app-server-0.150.1/schema/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.150.1/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ThreadReadParams.ts +9 -0
- package/contracts/codex-app-server-0.150.1/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/TurnInterruptParams.ts +5 -0
- package/contracts/codex-app-server-0.153.4/manifest.json +27 -0
- package/contracts/codex-app-server-0.153.4/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.153.4/schema/v1/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadStartParams.json +515 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnStartParams.json +921 -0
- package/contracts/codex-app-server-0.153.4/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ThreadReadParams.ts +12 -0
- package/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.ts +5 -0
- package/contracts/openhands-agent-server-v1.44.1.json +65 -0
- package/contracts/openhands-agent-server-v1.46.0.json +46 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.d.ts +9 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.d.ts +3 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.d.ts +1 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.d.ts +10 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.d.ts +8 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.d.ts +4 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.js +2 -0
- package/dist/src/agent-sdk-normalizer.d.ts +22 -0
- package/dist/src/agent-sdk-normalizer.js +995 -0
- package/dist/src/agent-sdk-run.d.ts +31 -0
- package/dist/src/agent-sdk-run.js +242 -0
- package/dist/src/agent-sdk-runner.d.ts +96 -0
- package/dist/src/agent-sdk-runner.js +407 -0
- package/dist/src/agent-sdk.d.ts +294 -0
- package/dist/src/agent-sdk.js +644 -0
- package/dist/src/aggregation.d.ts +183 -0
- package/dist/src/aggregation.js +513 -0
- package/dist/src/artifacts.d.ts +36 -0
- package/dist/src/artifacts.js +1372 -0
- package/dist/src/atlas-grafana.d.ts +377 -0
- package/dist/src/atlas-grafana.js +47 -0
- package/dist/src/atlas-html.d.ts +2 -0
- package/dist/src/atlas-html.js +34 -0
- package/dist/src/atlas.d.ts +130 -0
- package/dist/src/atlas.js +235 -0
- package/dist/src/behavior-assertions.d.ts +95 -0
- package/dist/src/behavior-assertions.js +154 -0
- package/dist/src/cli.d.ts +5 -0
- package/dist/src/cli.js +957 -0
- package/dist/src/codex-judge.d.ts +44 -0
- package/dist/src/codex-judge.js +214 -0
- package/dist/src/codex-run.d.ts +114 -0
- package/dist/src/codex-run.js +519 -0
- package/dist/src/codex.d.ts +187 -0
- package/dist/src/codex.js +1028 -0
- package/dist/src/contracts.d.ts +155 -0
- package/dist/src/contracts.js +866 -0
- package/dist/src/corpus.d.ts +67 -0
- package/dist/src/corpus.js +540 -0
- package/dist/src/cursor-sdk-runner.d.ts +81 -0
- package/dist/src/cursor-sdk-runner.js +362 -0
- package/dist/src/cursor-sdk.d.ts +81 -0
- package/dist/src/cursor-sdk.js +1078 -0
- package/dist/src/deepseek-adapter.d.ts +152 -0
- package/dist/src/deepseek-adapter.js +777 -0
- package/dist/src/exports.d.ts +69 -0
- package/dist/src/exports.js +966 -0
- package/dist/src/human-calibration.d.ts +167 -0
- package/dist/src/human-calibration.js +618 -0
- package/dist/src/index.d.ts +71 -0
- package/dist/src/index.js +38 -0
- package/dist/src/lifecycle.d.ts +196 -0
- package/dist/src/lifecycle.js +1889 -0
- package/dist/src/normalization-integrity.d.ts +128 -0
- package/dist/src/normalization-integrity.js +429 -0
- package/dist/src/openhands-run.d.ts +36 -0
- package/dist/src/openhands-run.js +415 -0
- package/dist/src/openhands.d.ts +130 -0
- package/dist/src/openhands.js +859 -0
- package/dist/src/pi.d.ts +203 -0
- package/dist/src/pi.js +1345 -0
- package/dist/src/process-protocol.d.ts +245 -0
- package/dist/src/process-protocol.js +1261 -0
- package/dist/src/retained-evidence.d.ts +12 -0
- package/dist/src/retained-evidence.js +201 -0
- package/dist/src/run-bundles.d.ts +193 -0
- package/dist/src/run-bundles.js +993 -0
- package/dist/src/scheduler.d.ts +109 -0
- package/dist/src/scheduler.js +1080 -0
- package/dist/src/semantic-judge.d.ts +194 -0
- package/dist/src/semantic-judge.js +875 -0
- package/dist/src/structural-observations.d.ts +124 -0
- package/dist/src/structural-observations.js +671 -0
- package/dist/src/task-packets.d.ts +115 -0
- package/dist/src/task-packets.js +683 -0
- package/dist/src/uniform-events.d.ts +120 -0
- package/dist/src/uniform-events.js +158 -0
- package/dist/src/verifiers.d.ts +116 -0
- package/dist/src/verifiers.js +819 -0
- package/dist/src/workspaces.d.ts +56 -0
- package/dist/src/workspaces.js +1314 -0
- package/docs/README.md +60 -0
- package/docs/development/README.md +71 -0
- package/docs/development/documentation-sync.md +58 -0
- package/docs/development/extension-contracts.md +307 -0
- package/docs/evaluation/README.md +48 -0
- package/docs/evaluation/aggregation.md +112 -0
- package/docs/evaluation/behavior-assertions.md +45 -0
- package/docs/evaluation/human-calibration.md +170 -0
- package/docs/evaluation/normalization-integrity.md +80 -0
- package/docs/evaluation/semantic-judge.md +188 -0
- package/docs/evaluation/structural-observations.md +84 -0
- package/docs/evaluation/uniform-events.md +131 -0
- package/docs/guides/atlas.md +144 -0
- package/docs/guides/evidence-and-sharing.md +100 -0
- package/docs/guides/operator-guide.md +424 -0
- package/docs/guides/quickstart.md +152 -0
- package/docs/guides/telemetry.md +66 -0
- package/docs/harnesses/README.md +31 -0
- package/docs/harnesses/claude-agent-sdk.md +61 -0
- package/docs/harnesses/codex-harness.md +174 -0
- package/docs/harnesses/cursor-sdk.md +149 -0
- package/docs/harnesses/deepseek-harness.md +134 -0
- package/docs/harnesses/openhands-agent-server.md +153 -0
- package/docs/harnesses/pi-sdk.md +66 -0
- package/docs/reference/README.md +19 -0
- package/docs/reference/agent-sdk-operational-runner.md +118 -0
- package/docs/reference/cli.md +114 -0
- package/docs/reference/contracts.md +222 -0
- package/docs/reference/run-bundle-contract.md +354 -0
- package/docs/reference/run-lifecycle.md +54 -0
- package/examples/cursor-sdk/README.md +11 -0
- package/examples/cursor-sdk/capture-profile.json +1 -0
- package/examples/cursor-sdk/harness.json +1 -0
- package/examples/cursor-sdk/model.json +1 -0
- package/examples/cursor-sdk/native-limits.json +1 -0
- package/examples/cursor-sdk/native-tool-policy.json +1 -0
- package/ontology/behavior-categories.v1.json +46 -0
- package/package.json +66 -0
- package/release/0.1.0/KNOWN_LIMITATIONS.md +35 -0
- package/release/0.1.0/README.md +46 -0
- package/release/0.1.0/reproducibility.json +61 -0
- package/release/0.2.0/KNOWN_LIMITATIONS.md +40 -0
- package/release/0.2.0/README.md +42 -0
- package/release/0.2.0/reproducibility.json +73 -0
- package/release/0.2.1/KNOWN_LIMITATIONS.md +18 -0
- package/release/0.2.1/README.md +63 -0
- package/release/0.2.1/reproducibility.json +73 -0
- package/release/README.md +13 -0
- package/schemas/aggregation.v1.json +293 -0
- package/schemas/behavior-assertions.v1.json +220 -0
- package/schemas/deepseek-runtime-composition.v1.schema.json +122 -0
- package/schemas/experiment.v1.schema.json +188 -0
- package/schemas/human-calibration.v1.json +336 -0
- package/schemas/normalization-integrity.v1.json +302 -0
- package/schemas/run-bundles/v1.json +1040 -0
- package/schemas/run-queue.v1.schema.json +206 -0
- package/schemas/semantic-judge.v1.json +380 -0
- package/schemas/structural-observations.v1.json +131 -0
- package/schemas/task-packet-freeze.v1.schema.json +106 -0
- package/schemas/task-packet.v1.schema.json +234 -0
- package/schemas/uniform-events/v1.json +289 -0
- package/scripts/atlas-grafana.sh +18 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
export const MAX_CONFIGURATION_BYTES = 1_048_576;
|
|
6
|
+
const MAX_ARCHIVE_PATH_COMPONENTS = 64;
|
|
7
|
+
const MAX_ARCHIVE_PATH_LENGTH = 960;
|
|
8
|
+
const TAR_BLOCK_BYTES = 512;
|
|
9
|
+
const MAX_TAR_OVERHEAD_PER_MEMBER = 4096;
|
|
10
|
+
export function assertDeclaredOrder(conditionSets, declaredOrder) {
|
|
11
|
+
assertExactIds("task", declaredOrder.taskIds, conditionSets.taskSet);
|
|
12
|
+
assertExactIds("model", declaredOrder.modelIds, conditionSets.modelSet);
|
|
13
|
+
assertExactIds("harness", declaredOrder.harnessIds, conditionSets.harnessSet);
|
|
14
|
+
}
|
|
15
|
+
export function* declaredMatrixCells(declaredOrder, trialCount) {
|
|
16
|
+
assertPositiveSafeInteger(trialCount, "Trial count");
|
|
17
|
+
for (const taskId of declaredOrder.taskIds) {
|
|
18
|
+
for (const modelId of declaredOrder.modelIds) {
|
|
19
|
+
for (const harnessId of declaredOrder.harnessIds) {
|
|
20
|
+
for (let trialIndex = 1; trialIndex <= trialCount; trialIndex += 1) {
|
|
21
|
+
yield { taskId, modelId, harnessId, trialIndex };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function assertNoSelectedSymlinks(entries, includePaths, archiveEntries) {
|
|
28
|
+
if (archiveEntries === undefined && includePaths.some((includePath) => {
|
|
29
|
+
const canonicalIncludePath = canonicalArchiveMemberPath(includePath);
|
|
30
|
+
return entries.some((entry) => {
|
|
31
|
+
const archivePath = canonicalArchiveMemberPath(entry.path);
|
|
32
|
+
return archivePath.startsWith(`${canonicalIncludePath}/`)
|
|
33
|
+
|| (entry.kind === "directory" && archivePath === canonicalIncludePath);
|
|
34
|
+
});
|
|
35
|
+
})) {
|
|
36
|
+
throw new Error("Directory includes require an authoritative archive enumeration.");
|
|
37
|
+
}
|
|
38
|
+
const authoritativeEntries = archiveEntries ?? entries;
|
|
39
|
+
const archiveByPath = new Map();
|
|
40
|
+
const destinations = new Map();
|
|
41
|
+
const destinationCollisionPaths = new Map();
|
|
42
|
+
const directoryPrefixes = new Map();
|
|
43
|
+
if (entries.length === 0) {
|
|
44
|
+
throw new Error("No archive entries were selected.");
|
|
45
|
+
}
|
|
46
|
+
for (const archiveEntry of authoritativeEntries) {
|
|
47
|
+
const archivePath = canonicalArchiveMemberPath(archiveEntry.path);
|
|
48
|
+
if (!isSafeArtifactRelativePath(archiveEntry.path)
|
|
49
|
+
|| (archiveEntry.kind === "file" && archiveEntry.path.endsWith("/"))
|
|
50
|
+
|| archiveByPath.has(archivePath)) {
|
|
51
|
+
throw new Error(`Archive entry "${archiveEntry.path}" is unsafe or collides with another archive member.`);
|
|
52
|
+
}
|
|
53
|
+
archiveByPath.set(archivePath, archiveEntry.kind);
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const destination = canonicalArchiveMemberPath(entry.path);
|
|
57
|
+
const collisionPath = destination.toLowerCase();
|
|
58
|
+
if (!["file", "directory"].includes(entry.kind) || !isSafeArtifactRelativePath(entry.path)
|
|
59
|
+
|| (entry.kind === "file" && entry.path.endsWith("/"))) {
|
|
60
|
+
throw new Error(`Selected archive entry "${entry.path}" is unsafe.`);
|
|
61
|
+
}
|
|
62
|
+
if (destinationCollisionPaths.has(collisionPath)) {
|
|
63
|
+
throw new Error(`Selected archive entry "${entry.path}" collides with another selected destination.`);
|
|
64
|
+
}
|
|
65
|
+
if (archiveByPath.get(destination) !== entry.kind) {
|
|
66
|
+
throw new Error(`Selected archive entry "${entry.path}" does not match its archive member kind.`);
|
|
67
|
+
}
|
|
68
|
+
destinations.set(destination, entry.kind);
|
|
69
|
+
destinationCollisionPaths.set(collisionPath, entry.kind);
|
|
70
|
+
assertCaseConsistentDirectoryPrefixes(destination, entry.kind, directoryPrefixes);
|
|
71
|
+
}
|
|
72
|
+
const archivePaths = [...archiveByPath.keys()].sort();
|
|
73
|
+
const includePathSet = new Set();
|
|
74
|
+
for (const includePath of includePaths) {
|
|
75
|
+
const canonicalIncludePath = canonicalArchiveMemberPath(includePath);
|
|
76
|
+
if (!isSafeArtifactRelativePath(includePath) || !hasPathOrDescendant(archivePaths, canonicalIncludePath)) {
|
|
77
|
+
throw new Error(`Archive include path "${includePath}" selected no entries.`);
|
|
78
|
+
}
|
|
79
|
+
includePathSet.add(canonicalIncludePath);
|
|
80
|
+
}
|
|
81
|
+
for (const archivePath of archivePaths) {
|
|
82
|
+
if (isWithinAllowlist(archivePath, includePathSet) && !destinations.has(archivePath)) {
|
|
83
|
+
throw new Error(`Archive include path omitted selected entry "${archivePath}".`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const path of destinations.keys()) {
|
|
87
|
+
if (!isWithinAllowlist(path, includePathSet)) {
|
|
88
|
+
throw new Error(`Selected archive entry "${path}" is outside the declared allowlist.`);
|
|
89
|
+
}
|
|
90
|
+
if (hasFileAncestor(path.toLowerCase(), destinationCollisionPaths)) {
|
|
91
|
+
throw new Error(`Selected archive entry "${path}" collides with a file destination.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function assertArchiveMeasurements(limits, measurements) {
|
|
96
|
+
if (![limits.maxCompressedBytes, limits.maxExpandedBytes, limits.maxMembers]
|
|
97
|
+
.every((value) => Number.isSafeInteger(value) && value >= 1)) {
|
|
98
|
+
throw new Error("Sanitized archive limits must be positive safe integers.");
|
|
99
|
+
}
|
|
100
|
+
if (![measurements.compressedBytes, measurements.expandedBytes, measurements.memberCount]
|
|
101
|
+
.every((value) => Number.isSafeInteger(value) && value >= 0)) {
|
|
102
|
+
throw new Error("Sanitized archive measurements must be nonnegative safe integers.");
|
|
103
|
+
}
|
|
104
|
+
if (measurements.compressedBytes > limits.maxCompressedBytes
|
|
105
|
+
|| measurements.expandedBytes > limits.maxExpandedBytes
|
|
106
|
+
|| measurements.memberCount > limits.maxMembers) {
|
|
107
|
+
throw new Error("Sanitized archive exceeds its declared materialization limits.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function validateTaskArchive(bytes, limits, includePaths) {
|
|
111
|
+
readTaskArchive(bytes, limits, includePaths);
|
|
112
|
+
}
|
|
113
|
+
export function readTaskArchive(bytes, limits, includePaths) {
|
|
114
|
+
if (![limits.maxCompressedBytes, limits.maxExpandedBytes, limits.maxMembers]
|
|
115
|
+
.every((value) => Number.isSafeInteger(value) && value >= 1)) {
|
|
116
|
+
throw new Error("Sanitized archive limits must be positive safe integers.");
|
|
117
|
+
}
|
|
118
|
+
if (bytes.byteLength > limits.maxCompressedBytes) {
|
|
119
|
+
throw new Error("Sanitized archive exceeds its declared materialization limits.");
|
|
120
|
+
}
|
|
121
|
+
let archive;
|
|
122
|
+
try {
|
|
123
|
+
archive = gunzipSync(Buffer.from(bytes), { maxOutputLength: archiveInspectionLimit(limits) });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error("Sanitized task archive is not a valid gzip stream or exceeds the inspection limit.");
|
|
127
|
+
}
|
|
128
|
+
const parsed = parseTarArchive(archive, limits.maxMembers);
|
|
129
|
+
assertArchiveMeasurements(limits, {
|
|
130
|
+
compressedBytes: bytes.byteLength,
|
|
131
|
+
expandedBytes: parsed.expandedBytes,
|
|
132
|
+
memberCount: parsed.entries.length,
|
|
133
|
+
});
|
|
134
|
+
const selected = parsed.entries.filter((entry) => includePaths.some((includePath) => {
|
|
135
|
+
const selectedPath = canonicalArchiveMemberPath(includePath);
|
|
136
|
+
const entryPath = canonicalArchiveMemberPath(entry.path);
|
|
137
|
+
return entryPath === selectedPath || entryPath.startsWith(`${selectedPath}/`);
|
|
138
|
+
}));
|
|
139
|
+
assertNoSelectedSymlinks(selected, includePaths, parsed.entries);
|
|
140
|
+
return selected;
|
|
141
|
+
}
|
|
142
|
+
export function resolveBundleConfiguration(bundleRoot, reference, maxBytes = MAX_CONFIGURATION_BYTES, rootHandle) {
|
|
143
|
+
assertPositiveSafeInteger(maxBytes, "Configuration maximum bytes");
|
|
144
|
+
const root = rootHandle ?? openBundleRoot(bundleRoot);
|
|
145
|
+
const ownsRoot = rootHandle === undefined;
|
|
146
|
+
try {
|
|
147
|
+
return readVerifiedBundleFile(openBundleRegularFile(bundleRoot, reference.locator, "Configuration locator", root), reference, "Configuration", maxBytes, root, bundleRoot);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (ownsRoot)
|
|
151
|
+
closeBundleRoot(root);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function resolveTaskArchive(bundleRoot, source, maxCompressedBytes, rootHandle) {
|
|
155
|
+
assertPositiveSafeInteger(maxCompressedBytes, "Task archive maximum compressed bytes");
|
|
156
|
+
const root = rootHandle ?? openBundleRoot(bundleRoot);
|
|
157
|
+
const ownsRoot = rootHandle === undefined;
|
|
158
|
+
try {
|
|
159
|
+
return readVerifiedBundleFile(openBundleRegularFile(bundleRoot, source.locator, "Task archive locator", root), source, "Task archive", maxCompressedBytes, root, bundleRoot);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (ownsRoot)
|
|
163
|
+
closeBundleRoot(root);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function resolveBundleArtifact(bundleRoot, reference, maxBytes, rootHandle) {
|
|
167
|
+
const root = rootHandle ?? openBundleRoot(bundleRoot);
|
|
168
|
+
const ownsRoot = rootHandle === undefined;
|
|
169
|
+
try {
|
|
170
|
+
return readVerifiedBundleFile(openBundleRegularFile(bundleRoot, reference.locator, "Artifact locator", root), reference, "Artifact", maxBytes, root, bundleRoot);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (ownsRoot)
|
|
174
|
+
closeBundleRoot(root);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function resolveBundleArtifactDigest(bundleRoot, reference, rootHandle) {
|
|
178
|
+
const root = rootHandle ?? openBundleRoot(bundleRoot);
|
|
179
|
+
const ownsRoot = rootHandle === undefined;
|
|
180
|
+
let descriptor;
|
|
181
|
+
try {
|
|
182
|
+
descriptor = openBundleRegularFile(bundleRoot, reference.locator, "Artifact locator", root);
|
|
183
|
+
const opened = fstatSync(descriptor);
|
|
184
|
+
const openedTimes = fstatSync(descriptor, { bigint: true });
|
|
185
|
+
const size = opened.size;
|
|
186
|
+
if (!opened.isFile() || !isReadableBundleFile(resolve(root.path, reference.locator), opened)
|
|
187
|
+
|| !Number.isSafeInteger(size) || size < 0) {
|
|
188
|
+
throw new Error("Artifact is not an isolated regular file.");
|
|
189
|
+
}
|
|
190
|
+
const hash = createHash("sha256");
|
|
191
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
192
|
+
for (let offset = 0; offset < size;) {
|
|
193
|
+
const read = readSync(descriptor, chunk, 0, Math.min(chunk.length, size - offset), offset);
|
|
194
|
+
if (read === 0)
|
|
195
|
+
throw new Error("Artifact changed while its digest was being read.");
|
|
196
|
+
hash.update(chunk.subarray(0, read));
|
|
197
|
+
offset += read;
|
|
198
|
+
}
|
|
199
|
+
const trailing = Buffer.allocUnsafe(1);
|
|
200
|
+
if (readSync(descriptor, trailing, 0, 1, size) !== 0) {
|
|
201
|
+
throw new Error("Artifact changed while its digest was being read.");
|
|
202
|
+
}
|
|
203
|
+
const completed = fstatSync(descriptor);
|
|
204
|
+
const completedTimes = fstatSync(descriptor, { bigint: true });
|
|
205
|
+
if (!completed.isFile() || !isReadableBundleFile(resolve(root.path, reference.locator), completed)
|
|
206
|
+
|| completed.dev !== opened.dev || completed.ino !== opened.ino
|
|
207
|
+
|| completed.size !== size || completedTimes.mtimeNs !== openedTimes.mtimeNs
|
|
208
|
+
|| completedTimes.ctimeNs !== openedTimes.ctimeNs) {
|
|
209
|
+
throw new Error("Artifact changed while its digest was being read.");
|
|
210
|
+
}
|
|
211
|
+
assertBundleRootHandle(root, bundleRoot, reference.locator);
|
|
212
|
+
const current = lstatSync(assertBundlePathWithoutLinks(root.path, reference.locator, "Artifact locator"));
|
|
213
|
+
if (current.dev !== completed.dev || current.ino !== completed.ino) {
|
|
214
|
+
throw new Error("Artifact changed after bundle-root verification.");
|
|
215
|
+
}
|
|
216
|
+
const resolvedDigest = { algorithm: "sha256", value: hash.digest("hex") };
|
|
217
|
+
if (reference.digest.algorithm !== resolvedDigest.algorithm || reference.digest.value !== resolvedDigest.value) {
|
|
218
|
+
throw new Error("Artifact digest does not match its source reference.");
|
|
219
|
+
}
|
|
220
|
+
return resolvedDigest;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
if (descriptor !== undefined)
|
|
224
|
+
closeSync(descriptor);
|
|
225
|
+
if (ownsRoot)
|
|
226
|
+
closeBundleRoot(root);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export function openBundleRoot(bundleRoot) {
|
|
230
|
+
const path = realpathSync(bundleRoot);
|
|
231
|
+
const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
232
|
+
const root = { path, descriptor };
|
|
233
|
+
try {
|
|
234
|
+
assertBundleRootHandle(root, bundleRoot, "bundle root");
|
|
235
|
+
return root;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
closeSync(descriptor);
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function closeBundleRoot(root) {
|
|
243
|
+
closeSync(root.descriptor);
|
|
244
|
+
}
|
|
245
|
+
function readVerifiedBundleFile(descriptor, reference, label, maxBytes, root, bundleRoot) {
|
|
246
|
+
try {
|
|
247
|
+
const opened = fstatSync(descriptor);
|
|
248
|
+
const openedTimes = fstatSync(descriptor, { bigint: true });
|
|
249
|
+
const size = opened.size;
|
|
250
|
+
if (!opened.isFile() || !isReadableBundleFile(root === undefined ? undefined : resolve(root.path, reference.locator), opened)
|
|
251
|
+
|| !Number.isSafeInteger(size) || size < 0
|
|
252
|
+
|| (maxBytes !== undefined && size > maxBytes)) {
|
|
253
|
+
throw new Error(`${label} exceeds its maximum bytes.`);
|
|
254
|
+
}
|
|
255
|
+
const bytes = Buffer.alloc(size);
|
|
256
|
+
for (let offset = 0; offset < size;) {
|
|
257
|
+
const read = readSync(descriptor, bytes, offset, size - offset, offset);
|
|
258
|
+
if (read === 0)
|
|
259
|
+
throw new Error(`${label} changed while it was being read.`);
|
|
260
|
+
offset += read;
|
|
261
|
+
}
|
|
262
|
+
const trailing = Buffer.allocUnsafe(1);
|
|
263
|
+
if (readSync(descriptor, trailing, 0, 1, size) !== 0) {
|
|
264
|
+
throw new Error(`${label} changed while it was being read.`);
|
|
265
|
+
}
|
|
266
|
+
const completed = fstatSync(descriptor);
|
|
267
|
+
const completedTimes = fstatSync(descriptor, { bigint: true });
|
|
268
|
+
if (!completed.isFile() || !isReadableBundleFile(root === undefined ? undefined : resolve(root.path, reference.locator), completed)
|
|
269
|
+
|| completed.dev !== opened.dev || completed.ino !== opened.ino
|
|
270
|
+
|| completed.size !== size || completedTimes.mtimeNs !== openedTimes.mtimeNs
|
|
271
|
+
|| completedTimes.ctimeNs !== openedTimes.ctimeNs
|
|
272
|
+
|| (maxBytes !== undefined && completed.size > maxBytes)) {
|
|
273
|
+
throw new Error(`${label} changed while it was being read.`);
|
|
274
|
+
}
|
|
275
|
+
if (root !== undefined && bundleRoot !== undefined) {
|
|
276
|
+
assertBundleRootHandle(root, bundleRoot, reference.locator);
|
|
277
|
+
const current = lstatSync(assertBundlePathWithoutLinks(root.path, reference.locator, `${label} locator`));
|
|
278
|
+
if (current.dev !== completed.dev || current.ino !== completed.ino) {
|
|
279
|
+
throw new Error(`${label} changed after bundle-root verification.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const resolvedDigest = {
|
|
283
|
+
algorithm: "sha256",
|
|
284
|
+
value: createHash("sha256").update(bytes).digest("hex"),
|
|
285
|
+
};
|
|
286
|
+
if (reference.digest.algorithm !== resolvedDigest.algorithm || reference.digest.value !== resolvedDigest.value) {
|
|
287
|
+
throw new Error(`${label} digest does not match its source reference.`);
|
|
288
|
+
}
|
|
289
|
+
return bytes;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
closeSync(descriptor);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export function openBundleRegularFile(bundleRoot, locator, label, root) {
|
|
296
|
+
if (!isSafeArtifactRelativePath(locator)) {
|
|
297
|
+
throw new Error(`${label} "${locator}" is unsafe.`);
|
|
298
|
+
}
|
|
299
|
+
assertBundleRootHandle(root, bundleRoot, locator);
|
|
300
|
+
let descriptor;
|
|
301
|
+
const originalCwd = process.cwd();
|
|
302
|
+
let changedCwd = false;
|
|
303
|
+
try {
|
|
304
|
+
// Node does not expose openat. Keep the verified root/parent directory as
|
|
305
|
+
// the process cwd while opening each next component with O_NOFOLLOW; once
|
|
306
|
+
// chdir has entered a verified directory, later lookups are inode-relative.
|
|
307
|
+
process.chdir(root.path);
|
|
308
|
+
changedCwd = true;
|
|
309
|
+
if (!sameFileIdentity(fstatSync(root.descriptor), lstatSync("."))) {
|
|
310
|
+
throw new Error(`${label} "${locator}" bundle root changed during verification.`);
|
|
311
|
+
}
|
|
312
|
+
let currentPath = root.path;
|
|
313
|
+
const segments = locator.split("/");
|
|
314
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
315
|
+
const segment = segments[index];
|
|
316
|
+
let childDescriptor;
|
|
317
|
+
try {
|
|
318
|
+
childDescriptor = openSync(segment, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error.code === "ELOOP") {
|
|
322
|
+
throw new Error(`${label} "${locator}" escapes its declared root.`);
|
|
323
|
+
}
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const openedDirectory = fstatSync(childDescriptor);
|
|
328
|
+
const namedDirectory = lstatSync(segment);
|
|
329
|
+
if (!openedDirectory.isDirectory() || namedDirectory.isSymbolicLink()
|
|
330
|
+
|| !sameFileIdentity(openedDirectory, namedDirectory)) {
|
|
331
|
+
throw new Error(`${label} "${locator}" crosses a symbolic link.`);
|
|
332
|
+
}
|
|
333
|
+
process.chdir(segment);
|
|
334
|
+
const currentDirectory = lstatSync(".");
|
|
335
|
+
if (!sameFileIdentity(openedDirectory, currentDirectory)) {
|
|
336
|
+
throw new Error(`${label} "${locator}" parent changed during verification.`);
|
|
337
|
+
}
|
|
338
|
+
currentPath = resolve(currentPath, segment);
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
closeSync(childDescriptor);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const leaf = segments[segments.length - 1];
|
|
345
|
+
try {
|
|
346
|
+
descriptor = openSync(leaf, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error.code === "ELOOP") {
|
|
350
|
+
throw new Error(`${label} "${locator}" escapes its declared root.`);
|
|
351
|
+
}
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
let opened = fstatSync(descriptor);
|
|
355
|
+
const current = lstatSync(leaf);
|
|
356
|
+
if (opened.nlink > 1) {
|
|
357
|
+
removeInterruptedPublicationLink(resolve(currentPath, leaf), opened);
|
|
358
|
+
opened = fstatSync(descriptor);
|
|
359
|
+
}
|
|
360
|
+
if (!opened.isFile() || current.isSymbolicLink() || !sameFileIdentity(opened, current)
|
|
361
|
+
|| !isReadableBundleFile(resolve(currentPath, leaf), opened)) {
|
|
362
|
+
throw new Error(`${label} "${locator}" is not an isolated regular file.`);
|
|
363
|
+
}
|
|
364
|
+
assertBundleRootHandle(root, root.path, locator);
|
|
365
|
+
return descriptor;
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (descriptor !== undefined)
|
|
369
|
+
closeSync(descriptor);
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
if (changedCwd)
|
|
374
|
+
process.chdir(originalCwd);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function assertBundleRootHandle(root, bundleRoot, locator) {
|
|
378
|
+
let requestedRoot;
|
|
379
|
+
try {
|
|
380
|
+
requestedRoot = realpathSync(bundleRoot);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
throw new Error(`Artifact path "${locator}" bundle root changed during verification.`);
|
|
384
|
+
}
|
|
385
|
+
const opened = fstatSync(root.descriptor);
|
|
386
|
+
const current = lstatSync(root.path);
|
|
387
|
+
if (requestedRoot !== root.path || !opened.isDirectory() || current.isSymbolicLink()
|
|
388
|
+
|| opened.dev !== current.dev || opened.ino !== current.ino) {
|
|
389
|
+
throw new Error(`Artifact path "${locator}" bundle root changed during verification.`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function assertBundlePathWithoutLinks(bundleRoot, locator, label) {
|
|
393
|
+
let selectedPath = bundleRoot;
|
|
394
|
+
const segments = locator.split("/");
|
|
395
|
+
for (const [index, segment] of segments.entries()) {
|
|
396
|
+
selectedPath = resolve(selectedPath, segment);
|
|
397
|
+
const entry = lstatSync(selectedPath);
|
|
398
|
+
if (!isContained(bundleRoot, selectedPath) || entry.isSymbolicLink()) {
|
|
399
|
+
throw new Error(`${label} "${locator}" escapes its bundle root.`);
|
|
400
|
+
}
|
|
401
|
+
if (index === segments.length - 1 && !entry.isFile()) {
|
|
402
|
+
throw new Error(`${label} "${locator}" is not an isolated regular file.`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return selectedPath;
|
|
406
|
+
}
|
|
407
|
+
function isReadableBundleFile(path, target) {
|
|
408
|
+
if (target.nlink !== 1)
|
|
409
|
+
return false;
|
|
410
|
+
if (path === undefined)
|
|
411
|
+
return true;
|
|
412
|
+
try {
|
|
413
|
+
const current = lstatSync(path);
|
|
414
|
+
return current.nlink === 1 && current.isFile() && !current.isSymbolicLink() && sameFileIdentity(current, target);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
export function removeInterruptedPublicationLink(path, target) {
|
|
421
|
+
for (const name of readdirSync(dirname(path))) {
|
|
422
|
+
if (!/^\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(name))
|
|
423
|
+
continue;
|
|
424
|
+
const temporaryPath = resolve(dirname(path), name);
|
|
425
|
+
if (temporaryPath === path)
|
|
426
|
+
continue;
|
|
427
|
+
try {
|
|
428
|
+
const candidate = lstatSync(temporaryPath);
|
|
429
|
+
if (candidate.isFile() && sameFileIdentity(candidate, target))
|
|
430
|
+
unlinkSync(temporaryPath);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (error.code !== "ENOENT")
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function sameFileIdentity(left, right) {
|
|
439
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
440
|
+
}
|
|
441
|
+
export function assertControlledPerturbationDigest(reference, resolvedDigest) {
|
|
442
|
+
if (reference.digest.algorithm !== resolvedDigest.algorithm
|
|
443
|
+
|| reference.digest.value !== resolvedDigest.value) {
|
|
444
|
+
throw new Error("Controlled perturbation digest does not match its reference.");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
export function assertResolvedExperimentConfigurationDigests(experiment, resolvedDigests) {
|
|
448
|
+
const modelDigests = new Set();
|
|
449
|
+
const harnessCompositions = new Set();
|
|
450
|
+
const references = [];
|
|
451
|
+
for (const [modelId, condition] of Object.entries(experiment.modelSet)) {
|
|
452
|
+
const identity = digestIdentity(condition.configurationRef.digest);
|
|
453
|
+
if (modelDigests.has(identity))
|
|
454
|
+
throw new Error(`Model "${modelId}" duplicates a configuration digest.`);
|
|
455
|
+
modelDigests.add(identity);
|
|
456
|
+
references.push(condition.configurationRef);
|
|
457
|
+
assertResolvedDigest(`model "${modelId}"`, condition.configurationRef, resolvedDigests);
|
|
458
|
+
}
|
|
459
|
+
for (const [harnessId, condition] of Object.entries(experiment.harnessSet)) {
|
|
460
|
+
const identity = [condition.configurationRef, condition.nativeLimitsRef, condition.nativeToolPolicyRef]
|
|
461
|
+
.map((reference) => digestIdentity(reference.digest)).join("|");
|
|
462
|
+
if (harnessCompositions.has(identity))
|
|
463
|
+
throw new Error(`Harness "${harnessId}" duplicates a composition.`);
|
|
464
|
+
harnessCompositions.add(identity);
|
|
465
|
+
references.push(condition.configurationRef, condition.nativeLimitsRef, condition.nativeToolPolicyRef);
|
|
466
|
+
assertResolvedDigest(`harness "${harnessId}"`, condition.configurationRef, resolvedDigests);
|
|
467
|
+
assertResolvedDigest(`harness limits "${harnessId}"`, condition.nativeLimitsRef, resolvedDigests);
|
|
468
|
+
assertResolvedDigest(`harness tool policy "${harnessId}"`, condition.nativeToolPolicyRef, resolvedDigests);
|
|
469
|
+
}
|
|
470
|
+
references.push(experiment.captureProfile);
|
|
471
|
+
assertResolvedDigest("capture profile", experiment.captureProfile, resolvedDigests);
|
|
472
|
+
if (experiment.ordering.strategy === "permuted" && experiment.ordering.permutationAlgorithmRef === undefined) {
|
|
473
|
+
throw new Error("Permuted experiment is missing its permutation algorithm reference.");
|
|
474
|
+
}
|
|
475
|
+
if ("permutationAlgorithmRef" in experiment.ordering
|
|
476
|
+
&& experiment.ordering.permutationAlgorithmRef !== undefined) {
|
|
477
|
+
references.push(experiment.ordering.permutationAlgorithmRef);
|
|
478
|
+
assertResolvedDigest("permutation algorithm", experiment.ordering.permutationAlgorithmRef, resolvedDigests);
|
|
479
|
+
}
|
|
480
|
+
references.push(...Object.values(experiment.taskSet).map((condition) => condition.packetRef));
|
|
481
|
+
assertDistinctBundleLocators(references);
|
|
482
|
+
}
|
|
483
|
+
export function assertAdmittedTaskPackets(taskSet, resolvedPackets) {
|
|
484
|
+
const packetDigests = new Set();
|
|
485
|
+
for (const [taskId, condition] of Object.entries(taskSet)) {
|
|
486
|
+
const packetDigest = `${condition.packetRef.digest.algorithm}:${condition.packetRef.digest.value}`;
|
|
487
|
+
if (packetDigests.has(packetDigest)) {
|
|
488
|
+
throw new Error(`Task packet "${taskId}" duplicates a packet digest.`);
|
|
489
|
+
}
|
|
490
|
+
packetDigests.add(packetDigest);
|
|
491
|
+
if (!Object.hasOwn(resolvedPackets, taskId)) {
|
|
492
|
+
throw new Error(`Task packet "${taskId}" did not resolve.`);
|
|
493
|
+
}
|
|
494
|
+
const packet = resolvedPackets[taskId];
|
|
495
|
+
if (packet.admission.status === "admitted" && (packet.preAdmissionDigest === null)) {
|
|
496
|
+
throw new Error(`Task packet "${taskId}" review is missing its pre-admission digest.`);
|
|
497
|
+
}
|
|
498
|
+
if (packet.admission.status === "admitted") {
|
|
499
|
+
assertRfc3339Timestamp(packet.admission.reviewedAt);
|
|
500
|
+
assertEqualDigests(`Task packet "${taskId}" review record`, packet.reviewRecordDigest, packet.resolvedReviewRecordDigest);
|
|
501
|
+
assertEqualDigests(`Task packet "${taskId}" reviewed pre-admission packet`, packet.preAdmissionDigest, packet.reviewRecordPreAdmissionDigest);
|
|
502
|
+
}
|
|
503
|
+
if (packet.digest.algorithm !== condition.packetRef.digest.algorithm
|
|
504
|
+
|| packet.digest.value !== condition.packetRef.digest.value) {
|
|
505
|
+
throw new Error(`Task packet "${taskId}" digest does not match its reference.`);
|
|
506
|
+
}
|
|
507
|
+
if (packet.admission.status !== "admitted") {
|
|
508
|
+
throw new Error(`Task packet "${taskId}" is not admitted.`);
|
|
509
|
+
}
|
|
510
|
+
if (packet.assessmentMode === "verified") {
|
|
511
|
+
assertEqualDigests(`Task packet "${taskId}" verifier`, packet.verifierDigest, packet.resolvedVerifierDigest);
|
|
512
|
+
}
|
|
513
|
+
else if (packet.assessmentMode !== "observational"
|
|
514
|
+
|| packet.verifierDigest !== null || packet.resolvedVerifierDigest !== null
|
|
515
|
+
|| packet.referenceSolution.declaration.status === "referenced"
|
|
516
|
+
|| packet.referenceSolution.resolvedDigest !== null) {
|
|
517
|
+
throw new Error(`Task packet "${taskId}" observational assessment cannot include verifier or reference-solution evidence.`);
|
|
518
|
+
}
|
|
519
|
+
if (packet.controlledPerturbation.declaration.status === "referenced") {
|
|
520
|
+
assertEqualDigests(`Task packet "${taskId}" controlled perturbation`, packet.controlledPerturbation.declaration.digest, packet.controlledPerturbation.resolvedDigest);
|
|
521
|
+
}
|
|
522
|
+
else if (packet.controlledPerturbation.resolvedDigest !== null) {
|
|
523
|
+
throw new Error(`Task packet "${taskId}" has resolved an unavailable controlled perturbation.`);
|
|
524
|
+
}
|
|
525
|
+
if (packet.referenceSolution.declaration.status === "referenced") {
|
|
526
|
+
assertEqualDigests(`Task packet "${taskId}" reference solution`, packet.referenceSolution.declaration.digest, packet.referenceSolution.resolvedDigest);
|
|
527
|
+
}
|
|
528
|
+
else if (packet.referenceSolution.resolvedDigest !== null) {
|
|
529
|
+
throw new Error(`Task packet "${taskId}" has resolved an unavailable reference solution.`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function assertEqualDigests(label, expected, resolved) {
|
|
534
|
+
if (expected === null || resolved === null
|
|
535
|
+
|| expected.algorithm !== resolved.algorithm || expected.value !== resolved.value) {
|
|
536
|
+
throw new Error(`${label} digest does not match its resolved bytes.`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function assertResolvedDigest(label, reference, resolvedDigests) {
|
|
540
|
+
if (!Object.hasOwn(resolvedDigests, reference.locator)) {
|
|
541
|
+
throw new Error(`${label} did not resolve.`);
|
|
542
|
+
}
|
|
543
|
+
const resolvedDigest = resolvedDigests[reference.locator];
|
|
544
|
+
if (reference.digest.algorithm !== resolvedDigest.algorithm
|
|
545
|
+
|| reference.digest.value !== resolvedDigest.value) {
|
|
546
|
+
throw new Error(`${label} digest does not match its reference.`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function assertDistinctBundleLocators(references) {
|
|
550
|
+
const locators = new Map();
|
|
551
|
+
const descendantPrefixes = new Set();
|
|
552
|
+
for (const reference of references) {
|
|
553
|
+
const locator = reference.locator.toLowerCase();
|
|
554
|
+
const existing = locators.get(locator);
|
|
555
|
+
if (existing !== undefined && existing !== reference.locator) {
|
|
556
|
+
throw new Error(`Bundle locator "${reference.locator}" case-aliases "${existing}".`);
|
|
557
|
+
}
|
|
558
|
+
if (existing !== undefined)
|
|
559
|
+
continue;
|
|
560
|
+
if (descendantPrefixes.has(locator)) {
|
|
561
|
+
throw new Error(`Bundle locator "${reference.locator}" aliases a descendant reference.`);
|
|
562
|
+
}
|
|
563
|
+
for (let boundary = locator.lastIndexOf("/"); boundary > 0; boundary = locator.lastIndexOf("/", boundary - 1)) {
|
|
564
|
+
if (locators.has(locator.slice(0, boundary))) {
|
|
565
|
+
throw new Error(`Bundle locator "${reference.locator}" aliases an ancestor reference.`);
|
|
566
|
+
}
|
|
567
|
+
descendantPrefixes.add(locator.slice(0, boundary));
|
|
568
|
+
}
|
|
569
|
+
locators.set(locator, reference.locator);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function digestIdentity(digest) {
|
|
573
|
+
return `${digest.algorithm}:${digest.value}`;
|
|
574
|
+
}
|
|
575
|
+
export function isSafeArtifactRelativePath(path) {
|
|
576
|
+
const segments = path.split("/");
|
|
577
|
+
return path === posix.normalize(path)
|
|
578
|
+
&& path.length <= MAX_ARCHIVE_PATH_LENGTH
|
|
579
|
+
&& /^(?!\/)(?!.*\/\/)(?!.*(?:^|\/)\.{1,2}(?:\/|$))[A-Za-z0-9._@+()\[\]\/-]+$/.test(path)
|
|
580
|
+
&& segments.length <= MAX_ARCHIVE_PATH_COMPONENTS
|
|
581
|
+
&& !segments.some((segment) => segment.length > 255 || segment.endsWith(".") || segment.toLowerCase() === ".git"
|
|
582
|
+
|| /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(segment));
|
|
583
|
+
}
|
|
584
|
+
function assertPositiveSafeInteger(value, label) {
|
|
585
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
586
|
+
throw new Error(`${label} must be a positive safe integer.`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function parseTarArchive(bytes, maxMembers) {
|
|
590
|
+
const entries = [];
|
|
591
|
+
let expandedBytes = 0;
|
|
592
|
+
let offset = 0;
|
|
593
|
+
let pendingPath;
|
|
594
|
+
let pendingPax;
|
|
595
|
+
while (offset + TAR_BLOCK_BYTES <= bytes.length) {
|
|
596
|
+
const header = bytes.subarray(offset, offset + TAR_BLOCK_BYTES);
|
|
597
|
+
if (header.every((value) => value === 0)) {
|
|
598
|
+
if (bytes.subarray(offset).every((value) => value === 0))
|
|
599
|
+
return { entries, expandedBytes };
|
|
600
|
+
throw new Error("Sanitized task archive has nonzero data after its end marker.");
|
|
601
|
+
}
|
|
602
|
+
verifyTarHeaderChecksum(header);
|
|
603
|
+
const headerSize = parseTarOctal(header.subarray(124, 136), "size");
|
|
604
|
+
const dataOffset = offset + TAR_BLOCK_BYTES;
|
|
605
|
+
const paddedSize = headerSize + ((TAR_BLOCK_BYTES - (headerSize % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES);
|
|
606
|
+
if (!Number.isSafeInteger(paddedSize) || dataOffset + paddedSize > bytes.length) {
|
|
607
|
+
throw new Error("Sanitized task archive contains a truncated member.");
|
|
608
|
+
}
|
|
609
|
+
const data = bytes.subarray(dataOffset, dataOffset + headerSize);
|
|
610
|
+
const type = String.fromCharCode(header[156] ?? 0);
|
|
611
|
+
if (type === "L") {
|
|
612
|
+
pendingPath = readTarText(data);
|
|
613
|
+
offset = dataOffset + paddedSize;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (type === "K") {
|
|
617
|
+
offset = dataOffset + paddedSize;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (type === "x") {
|
|
621
|
+
pendingPax = parsePaxAttributes(data);
|
|
622
|
+
offset = dataOffset + paddedSize;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (type === "X") {
|
|
626
|
+
throw new Error("Sanitized task archive contains an unsupported uppercase local PAX header.");
|
|
627
|
+
}
|
|
628
|
+
if (type === "g") {
|
|
629
|
+
throw new Error("Sanitized task archive contains an unsupported global PAX header.");
|
|
630
|
+
}
|
|
631
|
+
const rawName = readTarText(header.subarray(0, 100));
|
|
632
|
+
const prefix = readTarText(header.subarray(345, 500));
|
|
633
|
+
const magic = readTarText(header.subarray(257, 263));
|
|
634
|
+
const version = readTarText(header.subarray(263, 265));
|
|
635
|
+
if (magic !== "" && magic !== "ustar" && magic !== "ustar ") {
|
|
636
|
+
throw new Error("Sanitized task archive contains an unsupported TAR header format.");
|
|
637
|
+
}
|
|
638
|
+
if (prefix !== "" && !(magic === "ustar" && version === "00")) {
|
|
639
|
+
throw new Error("Sanitized task archive contains a TAR prefix without USTAR magic.");
|
|
640
|
+
}
|
|
641
|
+
const headerPath = prefix === "" ? rawName : `${prefix}/${rawName}`;
|
|
642
|
+
const path = pendingPax?.path ?? pendingPath ?? headerPath;
|
|
643
|
+
if (pendingPax?.size !== undefined && pendingPax.size !== headerSize) {
|
|
644
|
+
throw new Error("Sanitized task archive PAX size does not match TAR member framing.");
|
|
645
|
+
}
|
|
646
|
+
const size = pendingPax?.size ?? headerSize;
|
|
647
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
648
|
+
throw new Error("Sanitized task archive contains an invalid member size.");
|
|
649
|
+
const kind = tarEntryKind(type);
|
|
650
|
+
entries.push({ path, kind, bytes: data, mode: parseTarMode(header.subarray(100, 108)) });
|
|
651
|
+
if (entries.length > maxMembers)
|
|
652
|
+
throw new Error("Sanitized archive exceeds its declared materialization limits.");
|
|
653
|
+
if (kind === "file") {
|
|
654
|
+
if (!Number.isSafeInteger(expandedBytes + size))
|
|
655
|
+
throw new Error("Sanitized task archive exceeds safe expanded size.");
|
|
656
|
+
expandedBytes += size;
|
|
657
|
+
}
|
|
658
|
+
pendingPath = undefined;
|
|
659
|
+
pendingPax = undefined;
|
|
660
|
+
offset = dataOffset + paddedSize;
|
|
661
|
+
}
|
|
662
|
+
throw new Error("Sanitized task archive is not a complete TAR stream.");
|
|
663
|
+
}
|
|
664
|
+
function archiveInspectionLimit(limits) {
|
|
665
|
+
const overhead = MAX_TAR_OVERHEAD_PER_MEMBER * (limits.maxMembers + 2);
|
|
666
|
+
const limit = limits.maxExpandedBytes + overhead;
|
|
667
|
+
if (!Number.isSafeInteger(limit))
|
|
668
|
+
throw new Error("Sanitized archive inspection limit is not a safe integer.");
|
|
669
|
+
return limit;
|
|
670
|
+
}
|
|
671
|
+
function verifyTarHeaderChecksum(header) {
|
|
672
|
+
const expected = parseTarOctal(header.subarray(148, 156), "checksum");
|
|
673
|
+
let actual = 0;
|
|
674
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
675
|
+
actual += index >= 148 && index < 156 ? 0x20 : header[index];
|
|
676
|
+
}
|
|
677
|
+
if (actual !== expected)
|
|
678
|
+
throw new Error("Sanitized task archive contains an invalid TAR header checksum.");
|
|
679
|
+
}
|
|
680
|
+
function parseTarOctal(field, label) {
|
|
681
|
+
const raw = new TextDecoder("utf-8", { fatal: true }).decode(field).replace(/\0.*$/s, "");
|
|
682
|
+
const text = raw.replace(/^ +| +$/g, "");
|
|
683
|
+
if (text === "" || !/^[0-7]+$/.test(text))
|
|
684
|
+
throw new Error(`Sanitized task archive contains an invalid TAR ${label}.`);
|
|
685
|
+
const value = Number.parseInt(text, 8);
|
|
686
|
+
if (!Number.isSafeInteger(value))
|
|
687
|
+
throw new Error(`Sanitized task archive contains an unsafe TAR ${label}.`);
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
function parseTarMode(field) {
|
|
691
|
+
const raw = new TextDecoder("ascii", { fatal: true }).decode(field).replace(/\0.*$/s, "");
|
|
692
|
+
const text = raw.replace(/^ +| +$/g, "");
|
|
693
|
+
if (text === "")
|
|
694
|
+
return 0o644;
|
|
695
|
+
if (!/^[0-7]+$/.test(text))
|
|
696
|
+
throw new Error("Sanitized task archive contains an invalid TAR mode.");
|
|
697
|
+
const value = Number.parseInt(text, 8);
|
|
698
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
699
|
+
throw new Error("Sanitized task archive contains an unsafe TAR mode.");
|
|
700
|
+
return value;
|
|
701
|
+
}
|
|
702
|
+
function readTarText(bytes) {
|
|
703
|
+
const end = bytes.indexOf(0);
|
|
704
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, end < 0 ? bytes.length : end));
|
|
705
|
+
}
|
|
706
|
+
function parsePaxAttributes(bytes) {
|
|
707
|
+
const attributes = {};
|
|
708
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
709
|
+
const space = bytes.indexOf(0x20, offset);
|
|
710
|
+
if (space <= offset)
|
|
711
|
+
throw new Error("Sanitized task archive contains an invalid PAX header.");
|
|
712
|
+
const lengthText = new TextDecoder("ascii", { fatal: true }).decode(bytes.subarray(offset, space));
|
|
713
|
+
if (!/^[0-9]+$/.test(lengthText))
|
|
714
|
+
throw new Error("Sanitized task archive contains an invalid PAX record length.");
|
|
715
|
+
const length = Number.parseInt(lengthText, 10);
|
|
716
|
+
if (!Number.isSafeInteger(length) || length <= space - offset || offset + length > bytes.length) {
|
|
717
|
+
throw new Error("Sanitized task archive contains an invalid PAX record length.");
|
|
718
|
+
}
|
|
719
|
+
const record = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(space + 1, offset + length));
|
|
720
|
+
const equals = record.indexOf("=");
|
|
721
|
+
if (equals <= 0 || !record.endsWith("\n"))
|
|
722
|
+
throw new Error("Sanitized task archive contains an invalid PAX record.");
|
|
723
|
+
const key = record.slice(0, equals);
|
|
724
|
+
const value = record.slice(equals + 1, -1);
|
|
725
|
+
if (/^(?:GNU\.sparse(?:\.|$)|SCHILY\.(?:sparse(?:\.|$)|realsize$)|SUN\.holesdata$)/.test(key)) {
|
|
726
|
+
throw new Error("Sanitized task archive contains unsupported sparse PAX metadata.");
|
|
727
|
+
}
|
|
728
|
+
if (key === "path")
|
|
729
|
+
attributes.path = value;
|
|
730
|
+
if (key === "size") {
|
|
731
|
+
if (!/^[0-9]+$/.test(value))
|
|
732
|
+
throw new Error("Sanitized task archive contains an invalid PAX size.");
|
|
733
|
+
const size = Number(value);
|
|
734
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
735
|
+
throw new Error("Sanitized task archive contains an invalid PAX size.");
|
|
736
|
+
attributes.size = size;
|
|
737
|
+
}
|
|
738
|
+
offset += length;
|
|
739
|
+
}
|
|
740
|
+
return attributes;
|
|
741
|
+
}
|
|
742
|
+
function tarEntryKind(type) {
|
|
743
|
+
switch (type) {
|
|
744
|
+
case "\0":
|
|
745
|
+
case "0":
|
|
746
|
+
return "file";
|
|
747
|
+
case "5":
|
|
748
|
+
return "directory";
|
|
749
|
+
case "1":
|
|
750
|
+
return "hardlink";
|
|
751
|
+
case "2":
|
|
752
|
+
return "symlink";
|
|
753
|
+
case "3":
|
|
754
|
+
return "character-device";
|
|
755
|
+
case "4":
|
|
756
|
+
return "block-device";
|
|
757
|
+
case "6":
|
|
758
|
+
return "fifo";
|
|
759
|
+
case "7":
|
|
760
|
+
return "contiguous-file";
|
|
761
|
+
default:
|
|
762
|
+
return "special";
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function canonicalArchiveMemberPath(path) {
|
|
766
|
+
return posix.normalize(path).replace(/\/+$/, "");
|
|
767
|
+
}
|
|
768
|
+
function isWithinAllowlist(path, includePaths) {
|
|
769
|
+
for (let boundary = path.length; boundary >= 0; boundary = path.lastIndexOf("/", boundary - 1)) {
|
|
770
|
+
if (includePaths.has(path.slice(0, boundary)))
|
|
771
|
+
return true;
|
|
772
|
+
if (boundary === 0)
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
function hasFileAncestor(path, destinations) {
|
|
778
|
+
for (let boundary = path.lastIndexOf("/"); boundary > 0; boundary = path.lastIndexOf("/", boundary - 1)) {
|
|
779
|
+
if (destinations.get(path.slice(0, boundary)) === "file")
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
function assertCaseConsistentDirectoryPrefixes(path, kind, prefixes) {
|
|
785
|
+
const segments = path.split("/");
|
|
786
|
+
const directoryCount = kind === "directory" ? segments.length : segments.length - 1;
|
|
787
|
+
let prefix = "";
|
|
788
|
+
for (let index = 0; index < directoryCount; index += 1) {
|
|
789
|
+
prefix = index === 0 ? segments[index] : `${prefix}/${segments[index]}`;
|
|
790
|
+
const key = prefix.toLowerCase();
|
|
791
|
+
const existing = prefixes.get(key);
|
|
792
|
+
if (existing !== undefined && existing !== prefix) {
|
|
793
|
+
throw new Error(`Selected archive entry "${path}" has a case-inconsistent ancestor.`);
|
|
794
|
+
}
|
|
795
|
+
prefixes.set(key, prefix);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function hasPathOrDescendant(paths, path) {
|
|
799
|
+
const exact = lowerBound(paths, path);
|
|
800
|
+
const descendant = lowerBound(paths, `${path}/`);
|
|
801
|
+
return paths[exact] === path || paths[descendant]?.startsWith(`${path}/`) === true;
|
|
802
|
+
}
|
|
803
|
+
function lowerBound(paths, path) {
|
|
804
|
+
let low = 0;
|
|
805
|
+
let high = paths.length;
|
|
806
|
+
while (low < high) {
|
|
807
|
+
const middle = Math.floor((low + high) / 2);
|
|
808
|
+
if (paths[middle] < path)
|
|
809
|
+
low = middle + 1;
|
|
810
|
+
else
|
|
811
|
+
high = middle;
|
|
812
|
+
}
|
|
813
|
+
return low;
|
|
814
|
+
}
|
|
815
|
+
function assertRfc3339Timestamp(value) {
|
|
816
|
+
const match = value?.match(/^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/);
|
|
817
|
+
if (match === null || match === undefined) {
|
|
818
|
+
throw new Error("Admitted task packet review must have an RFC 3339 timestamp.");
|
|
819
|
+
}
|
|
820
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetSign, offsetHourText, offsetMinuteText] = match;
|
|
821
|
+
const year = Number(yearText);
|
|
822
|
+
const month = Number(monthText);
|
|
823
|
+
const day = Number(dayText);
|
|
824
|
+
const hour = Number(hourText);
|
|
825
|
+
const minute = Number(minuteText);
|
|
826
|
+
const second = Number(secondText);
|
|
827
|
+
const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText);
|
|
828
|
+
const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText);
|
|
829
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
830
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
831
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1]
|
|
832
|
+
|| hour > 23 || minute > 59 || second > 60 || offsetHour > 23 || offsetMinute > 59) {
|
|
833
|
+
throw new Error("Admitted task packet review must have an RFC 3339 timestamp.");
|
|
834
|
+
}
|
|
835
|
+
if (second === 60) {
|
|
836
|
+
const offsetMinutes = offsetSign === undefined ? 0
|
|
837
|
+
: (offsetSign === "+" ? 1 : -1) * (offsetHour * 60 + offsetMinute);
|
|
838
|
+
const instant = new Date(Date.UTC(year, month - 1, day, hour, minute, 59) - offsetMinutes * 60_000);
|
|
839
|
+
const utcDate = `${instant.getUTCFullYear()}-${String(instant.getUTCMonth() + 1).padStart(2, "0")}-${String(instant.getUTCDate()).padStart(2, "0")}`;
|
|
840
|
+
if (instant.getUTCHours() !== 23 || instant.getUTCMinutes() !== 59 || !KNOWN_LEAP_SECOND_DATES.has(utcDate)) {
|
|
841
|
+
throw new Error("Admitted task packet review must have an RFC 3339 timestamp.");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// Update when IERS announces a future UTC leap second.
|
|
846
|
+
const KNOWN_LEAP_SECOND_DATES = new Set([
|
|
847
|
+
"1972-06-30", "1972-12-31", "1973-12-31", "1974-12-31", "1975-12-31", "1976-12-31", "1977-12-31",
|
|
848
|
+
"1978-12-31", "1979-12-31", "1981-06-30", "1982-06-30", "1983-06-30", "1985-06-30", "1987-12-31",
|
|
849
|
+
"1989-12-31", "1990-12-31", "1992-06-30", "1993-06-30", "1994-06-30", "1995-12-31", "1997-06-30",
|
|
850
|
+
"1998-12-31", "2005-12-31", "2008-12-31", "2012-06-30", "2015-06-30", "2016-12-31",
|
|
851
|
+
]);
|
|
852
|
+
function isContained(root, path) {
|
|
853
|
+
const pathFromRoot = relative(root, path);
|
|
854
|
+
return pathFromRoot === "" || (!isAbsolute(pathFromRoot)
|
|
855
|
+
&& pathFromRoot !== ".."
|
|
856
|
+
&& !pathFromRoot.startsWith(`..${sep}`));
|
|
857
|
+
}
|
|
858
|
+
function assertExactIds(kind, declaredIds, conditions) {
|
|
859
|
+
const conditionIds = new Set(Object.keys(conditions));
|
|
860
|
+
const declaredIdSet = new Set(declaredIds);
|
|
861
|
+
if (declaredIds.length !== conditionIds.size
|
|
862
|
+
|| declaredIdSet.size !== conditionIds.size
|
|
863
|
+
|| declaredIds.some((id) => !conditionIds.has(id))) {
|
|
864
|
+
throw new Error(`Declared ${kind} IDs must exactly match the condition set.`);
|
|
865
|
+
}
|
|
866
|
+
}
|