specpi 0.10.0 → 0.11.2
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/CHANGELOG.md +169 -150
- package/LICENSE +21 -21
- package/NPM_RELEASE.md +110 -110
- package/README.md +223 -155
- package/SECURITY.md +86 -85
- package/SECURITY_MODEL.md +123 -107
- package/THIRD_PARTY.md +61 -61
- package/browser-runtime/package-lock.json +86 -86
- package/browser-runtime/package.json +15 -15
- package/extensions/command-guard/powershell-parser.ps1 +47 -47
- package/extensions/spec.ts +236 -10
- package/extensions/tool-wishlist/capabilities.json +114 -114
- package/extensions/tool-wishlist/core.mjs +285 -24
- package/extensions/tool-wishlist/index.ts +939 -90
- package/extensions/tool-wishlist/verification.mjs +810 -0
- package/extensions/workflow-controls/challenge.mjs +139 -13
- package/extensions/workflow-controls/index.ts +811 -35
- package/extensions/workflow-controls/scope.mjs +41 -5
- package/extensions/workflow-controls/smoke.mjs +55 -0
- package/extensions/workflow-controls/task-contract.mjs +439 -0
- package/package.json +98 -98
- package/scripts/check-package.mjs +3 -0
- package/scripts/check-pi-package.mjs +103 -4
- package/scripts/pi-test-harness.mjs +309 -0
- package/scripts/specpi.mjs +31 -2
- package/skills/specpi-improve/SKILL.md +60 -54
- package/specpi +0 -0
- package/templates/AGENTS.md +25 -23
- package/templates/settings.json +10 -10
- package/themes/specpi-spec.json +96 -96
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { markdownPathLabel } from "../workflow-controls/task-contract.mjs";
|
|
5
|
+
|
|
6
|
+
export const VERIFICATION_SCHEMA = 1;
|
|
7
|
+
export const MAX_SOURCE_FILES = 2048;
|
|
8
|
+
export const MAX_SOURCE_FILE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_SOURCE_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
export const MAX_SOURCE_DIRECTORY_DEPTH = 12;
|
|
11
|
+
export const MAX_SOURCE_DIRECTORIES = 512;
|
|
12
|
+
export const MAX_RECEIPT_GATES = 8;
|
|
13
|
+
|
|
14
|
+
// These are the source and verification inputs that can affect a SpecPi
|
|
15
|
+
// checkout. Keep the inventory explicit so a user's neighbouring files are
|
|
16
|
+
// never pulled into a proof merely because they happen to live in the root.
|
|
17
|
+
export const SOURCE_DIRECTORIES = Object.freeze([
|
|
18
|
+
".github",
|
|
19
|
+
"browser-runtime",
|
|
20
|
+
"extensions",
|
|
21
|
+
"scripts",
|
|
22
|
+
"shell",
|
|
23
|
+
"site",
|
|
24
|
+
"skills",
|
|
25
|
+
"templates",
|
|
26
|
+
"tests",
|
|
27
|
+
"themes",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const SOURCE_ROOT_FILES = Object.freeze([
|
|
31
|
+
"AGENTS.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
".editorconfig",
|
|
34
|
+
".gitattributes",
|
|
35
|
+
".gitignore",
|
|
36
|
+
".prettierignore",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"NPM_RELEASE.md",
|
|
39
|
+
"PLAN.md",
|
|
40
|
+
"README.md",
|
|
41
|
+
"SECURITY.md",
|
|
42
|
+
"SECURITY_MODEL.md",
|
|
43
|
+
"THIRD_PARTY.md",
|
|
44
|
+
"eslint.config.js",
|
|
45
|
+
"package.json",
|
|
46
|
+
"prettier.config.mjs",
|
|
47
|
+
"specpi",
|
|
48
|
+
"specpi.cmd",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const EXCLUDED_DIRECTORY_NAMES = new Set([
|
|
52
|
+
".git",
|
|
53
|
+
".next",
|
|
54
|
+
"build",
|
|
55
|
+
"coverage",
|
|
56
|
+
"desktop",
|
|
57
|
+
"dist",
|
|
58
|
+
"generated",
|
|
59
|
+
"node_modules",
|
|
60
|
+
"out",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const SENSITIVE_DIRECTORY_NAMES = new Set([
|
|
64
|
+
"auth",
|
|
65
|
+
"credentials",
|
|
66
|
+
"history",
|
|
67
|
+
"missions",
|
|
68
|
+
"private",
|
|
69
|
+
"secrets",
|
|
70
|
+
"sessions",
|
|
71
|
+
"tokens",
|
|
72
|
+
"trust",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const SENSITIVE_FILENAME =
|
|
76
|
+
/(?:^|[._-])(?:\.env(?:\..*)?|auth(?:entication)?|credential(?:s)?|secret(?:s)?|token(?:s)?|password(?:s)?|passwd|trust|history|mission(?:s)?)(?:[._-]|$)|(?:^|[._-])session(?:s)?\.(?:json|jsonl|db|sqlite|log)$|\.(?:crt|der|key|pem|pfx|p12)$/iu;
|
|
77
|
+
const SAFE_HEAD = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,160}$/u;
|
|
78
|
+
const SAFE_GATE_ID = /^(?=.*[A-Za-z0-9])[A-Za-z0-9 ._:-]{1,100}$/u;
|
|
79
|
+
|
|
80
|
+
function portableRelative(value) {
|
|
81
|
+
return value.split(path.sep).join("/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertDirectory(root) {
|
|
85
|
+
if (typeof root !== "string" || !root.trim()) {
|
|
86
|
+
throw new Error("Verification source root must be a non-empty path");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolved = path.resolve(root);
|
|
90
|
+
let stat;
|
|
91
|
+
try {
|
|
92
|
+
stat = fs.lstatSync(resolved);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Verification source root is unavailable: ${markdownPathLabel(error instanceof Error ? error.message : String(error))}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
100
|
+
throw new Error("Verification source root must be a real directory");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return fs.realpathSync.native(resolved);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function canonicalSourceRoot(root) {
|
|
107
|
+
return assertDirectory(root);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertRelativePath(relativePath) {
|
|
111
|
+
if (
|
|
112
|
+
typeof relativePath !== "string" ||
|
|
113
|
+
!relativePath ||
|
|
114
|
+
relativePath.startsWith("/") ||
|
|
115
|
+
/^[A-Za-z]:[\\/]/u.test(relativePath) ||
|
|
116
|
+
/[\u0000-\u001f\u007f]/u.test(relativePath)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error("Verification inventory contains an unsafe relative path");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
122
|
+
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
123
|
+
throw new Error("Verification inventory contains traversal or empty path segments");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isExcludedDirectory(name) {
|
|
130
|
+
return EXCLUDED_DIRECTORY_NAMES.has(name.toLowerCase());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isSensitiveFilename(name) {
|
|
134
|
+
return SENSITIVE_FILENAME.test(name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ensureInside(root, candidate) {
|
|
138
|
+
const resolvedRoot = path.resolve(root);
|
|
139
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
140
|
+
const lowerRoot = process.platform === "win32" ? resolvedRoot.toLowerCase() : resolvedRoot;
|
|
141
|
+
const lowerCandidate = process.platform === "win32" ? resolvedCandidate.toLowerCase() : resolvedCandidate;
|
|
142
|
+
if (lowerCandidate !== lowerRoot && !lowerCandidate.startsWith(`${lowerRoot}${path.sep}`)) {
|
|
143
|
+
throw new Error("Verification inventory path escapes the source root");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertRealDirectory(root, candidate, relativeDirectory) {
|
|
148
|
+
const stat = fs.lstatSync(candidate);
|
|
149
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Verification source directory is not a real directory: ${markdownPathLabel(relativeDirectory)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const real = fs.realpathSync.native(candidate);
|
|
156
|
+
ensureInside(root, real);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function lstatIfPresent(candidate) {
|
|
160
|
+
try {
|
|
161
|
+
return fs.lstatSync(candidate);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.code === "ENOENT") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertRealAncestors(root, relativePath) {
|
|
172
|
+
const parts = relativePath.split("/");
|
|
173
|
+
let current = root;
|
|
174
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
175
|
+
const part = parts[index];
|
|
176
|
+
current = path.join(current, part);
|
|
177
|
+
ensureInside(root, current);
|
|
178
|
+
assertRealDirectory(root, current, parts.slice(0, index + 1).join("/"));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listDirectory(root, relativeDirectory, files, context) {
|
|
183
|
+
context.directories += 1;
|
|
184
|
+
if (context.directories > MAX_SOURCE_DIRECTORIES) {
|
|
185
|
+
throw new Error(`Verification source inventory exceeds ${MAX_SOURCE_DIRECTORIES} directories`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const depth = relativeDirectory.split("/").length;
|
|
189
|
+
if (depth > MAX_SOURCE_DIRECTORY_DEPTH) {
|
|
190
|
+
throw new Error(`Verification source inventory exceeds depth ${MAX_SOURCE_DIRECTORY_DEPTH}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const directory = path.join(root, ...relativeDirectory.split("/"));
|
|
194
|
+
ensureInside(root, directory);
|
|
195
|
+
assertRealDirectory(root, directory, relativeDirectory);
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs
|
|
199
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
200
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Verification inventory could not enumerate ${markdownPathLabel(relativeDirectory)}: ${markdownPathLabel(error instanceof Error ? error.message : String(error))}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
if (!entry.name || entry.name === "." || entry.name === ".." || /[\u0000-\u001f\u007f]/u.test(entry.name)) {
|
|
209
|
+
throw new Error("Verification inventory contains an unsafe filename");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const relativePath = assertRelativePath(`${relativeDirectory}/${entry.name}`);
|
|
213
|
+
if (entry.isDirectory()) {
|
|
214
|
+
if (!isExcludedDirectory(entry.name)) {
|
|
215
|
+
if (SENSITIVE_DIRECTORY_NAMES.has(entry.name.toLowerCase())) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Verification inventory refuses sensitive source directory: ${markdownPathLabel(relativePath)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
listDirectory(root, relativePath, files, context);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (entry.isSymbolicLink()) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Verification inventory refuses symlinked source input: ${markdownPathLabel(relativePath)}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!entry.isFile()) {
|
|
234
|
+
throw new Error(`Verification inventory refuses non-file source input: ${markdownPathLabel(relativePath)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (isSensitiveFilename(entry.name)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Verification inventory refuses sensitive source filename: ${markdownPathLabel(relativePath)}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (files.length >= MAX_SOURCE_FILES) {
|
|
244
|
+
throw new Error(`Verification source inventory exceeds ${MAX_SOURCE_FILES} files`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
files.push(relativePath);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function enumerateSourceFiles(root) {
|
|
252
|
+
assertNoRootNpmrc(root);
|
|
253
|
+
|
|
254
|
+
const files = [];
|
|
255
|
+
const context = { directories: 0 };
|
|
256
|
+
for (const relativeDirectory of SOURCE_DIRECTORIES) {
|
|
257
|
+
const directory = path.join(root, relativeDirectory);
|
|
258
|
+
ensureInside(root, directory);
|
|
259
|
+
const directoryStat = lstatIfPresent(directory);
|
|
260
|
+
if (!directoryStat) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Verification source directory is not a real directory: ${markdownPathLabel(relativeDirectory)}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
assertRealDirectory(root, directory, relativeDirectory);
|
|
271
|
+
|
|
272
|
+
if (!isExcludedDirectory(relativeDirectory)) {
|
|
273
|
+
if (SENSITIVE_DIRECTORY_NAMES.has(relativeDirectory.toLowerCase())) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Verification inventory refuses sensitive source directory: ${markdownPathLabel(relativeDirectory)}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
listDirectory(root, relativeDirectory, files, context);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const name of SOURCE_ROOT_FILES) {
|
|
284
|
+
if (isSensitiveFilename(name)) {
|
|
285
|
+
throw new Error(`Verification inventory refuses sensitive source filename: ${markdownPathLabel(name)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const candidate = path.join(root, name);
|
|
289
|
+
ensureInside(root, candidate);
|
|
290
|
+
const stat = lstatIfPresent(candidate);
|
|
291
|
+
if (!stat) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
296
|
+
throw new Error(`Verification root source input is not a regular file: ${markdownPathLabel(name)}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (files.length >= MAX_SOURCE_FILES) {
|
|
300
|
+
throw new Error(`Verification source inventory exceeds ${MAX_SOURCE_FILES} files`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
files.push(name);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const unique = [...new Set(files)].sort();
|
|
307
|
+
if (unique.length === 0) {
|
|
308
|
+
throw new Error("Verification source inventory is empty");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (unique.length > MAX_SOURCE_FILES) {
|
|
312
|
+
throw new Error(`Verification source inventory exceeds ${MAX_SOURCE_FILES} files`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return unique;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isKnownInventoryPath(relativePath) {
|
|
319
|
+
if (SOURCE_ROOT_FILES.includes(relativePath)) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const topLevel = relativePath.split("/")[0];
|
|
324
|
+
|
|
325
|
+
return SOURCE_DIRECTORIES.includes(topLevel);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function assertNoRootNpmrc(root) {
|
|
329
|
+
const resolvedRoot = canonicalSourceRoot(root);
|
|
330
|
+
const npmrc = path.join(resolvedRoot, ".npmrc");
|
|
331
|
+
if (lstatIfPresent(npmrc)) {
|
|
332
|
+
throw new Error("Verification refuses a root .npmrc because it can alter npm script execution");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function assertSafeSourceInput(root, relativePath) {
|
|
337
|
+
const resolvedRoot = canonicalSourceRoot(root);
|
|
338
|
+
const safePath = assertRelativePath(relativePath);
|
|
339
|
+
const basename = safePath.split("/").at(-1);
|
|
340
|
+
if (
|
|
341
|
+
!isKnownInventoryPath(safePath) ||
|
|
342
|
+
isSensitiveFilename(basename) ||
|
|
343
|
+
safePath.split("/").some((part) => isExcludedDirectory(part))
|
|
344
|
+
) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Verification source input is outside the closed safe inventory: ${markdownPathLabel(safePath)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
assertRealAncestors(resolvedRoot, safePath);
|
|
351
|
+
const candidate = path.join(resolvedRoot, ...safePath.split("/"));
|
|
352
|
+
ensureInside(resolvedRoot, candidate);
|
|
353
|
+
const stat = fs.lstatSync(candidate);
|
|
354
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
355
|
+
throw new Error(`Verification source input is not a regular file: ${markdownPathLabel(safePath)}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (stat.size > MAX_SOURCE_FILE_BYTES) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Verification source input exceeds ${MAX_SOURCE_FILE_BYTES} bytes: ${markdownPathLabel(safePath)}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return safePath;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function normalizeIgnoredPaths(root, ignoredPaths) {
|
|
368
|
+
if (ignoredPaths === undefined) {
|
|
369
|
+
return new Set();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!Array.isArray(ignoredPaths) && !(ignoredPaths instanceof Set)) {
|
|
373
|
+
throw new Error("Verification ignored paths must be an array or set");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const normalized = new Set();
|
|
377
|
+
for (const value of ignoredPaths) {
|
|
378
|
+
const candidate = String(value).replace(/[\\/]+$/u, "");
|
|
379
|
+
if (!candidate) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const relativePath = assertRelativePath(candidate);
|
|
384
|
+
ensureInside(root, path.join(root, ...relativePath.split("/")));
|
|
385
|
+
normalized.add(relativePath);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return normalized;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function hashBuffer(value) {
|
|
392
|
+
return createHash("sha256").update(value).digest("hex");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function saltedSourceRootIdentity(root, salt) {
|
|
396
|
+
const resolvedRoot = canonicalSourceRoot(root);
|
|
397
|
+
if (typeof salt !== "string" || salt.length < 16 || salt.length > 256) {
|
|
398
|
+
throw new Error("Verification source-root salt is invalid");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return hashBuffer(Buffer.from(`${salt}\0${resolvedRoot}`, "utf8"));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readRegularFile(root, relativePath, { includeContent = false } = {}) {
|
|
405
|
+
const candidate = path.join(root, ...relativePath.split("/"));
|
|
406
|
+
ensureInside(root, candidate);
|
|
407
|
+
assertRealAncestors(root, relativePath);
|
|
408
|
+
const before = fs.lstatSync(candidate);
|
|
409
|
+
if (before.isSymbolicLink() || !before.isFile()) {
|
|
410
|
+
throw new Error(`Verification source input changed type before read: ${markdownPathLabel(relativePath)}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (before.size > MAX_SOURCE_FILE_BYTES) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Verification source input exceeds ${MAX_SOURCE_FILE_BYTES} bytes: ${markdownPathLabel(relativePath)}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
|
|
420
|
+
const descriptor = fs.openSync(candidate, flags);
|
|
421
|
+
let content;
|
|
422
|
+
try {
|
|
423
|
+
const opened = fs.fstatSync(descriptor);
|
|
424
|
+
if (
|
|
425
|
+
opened.isSymbolicLink() ||
|
|
426
|
+
!opened.isFile() ||
|
|
427
|
+
opened.size !== before.size ||
|
|
428
|
+
opened.mode !== before.mode ||
|
|
429
|
+
(opened.dev !== undefined && before.dev !== undefined && opened.dev !== before.dev) ||
|
|
430
|
+
(opened.ino !== undefined && before.ino !== undefined && opened.ino !== before.ino)
|
|
431
|
+
) {
|
|
432
|
+
throw new Error(`Verification source input changed before read: ${markdownPathLabel(relativePath)}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
content = Buffer.alloc(before.size);
|
|
436
|
+
let offset = 0;
|
|
437
|
+
while (offset < content.length) {
|
|
438
|
+
const read = fs.readSync(descriptor, content, offset, content.length - offset, offset);
|
|
439
|
+
if (read <= 0) {
|
|
440
|
+
throw new Error(`Verification source input shrank during read: ${markdownPathLabel(relativePath)}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
offset += read;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const extra = Buffer.alloc(1);
|
|
447
|
+
if (fs.readSync(descriptor, extra, 0, 1, before.size) > 0) {
|
|
448
|
+
throw new Error(`Verification source input grew during read: ${markdownPathLabel(relativePath)}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const after = fs.fstatSync(descriptor);
|
|
452
|
+
if (
|
|
453
|
+
after.size !== before.size ||
|
|
454
|
+
after.mtimeMs !== opened.mtimeMs ||
|
|
455
|
+
after.mode !== opened.mode ||
|
|
456
|
+
(after.dev !== undefined && opened.dev !== undefined && after.dev !== opened.dev) ||
|
|
457
|
+
(after.ino !== undefined && opened.ino !== undefined && after.ino !== opened.ino)
|
|
458
|
+
) {
|
|
459
|
+
throw new Error(`Verification source input changed during read: ${markdownPathLabel(relativePath)}`);
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
fs.closeSync(descriptor);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
assertRealAncestors(root, relativePath);
|
|
466
|
+
const afterPath = fs.lstatSync(candidate);
|
|
467
|
+
if (
|
|
468
|
+
afterPath.isSymbolicLink() ||
|
|
469
|
+
!afterPath.isFile() ||
|
|
470
|
+
afterPath.size !== before.size ||
|
|
471
|
+
afterPath.mode !== before.mode ||
|
|
472
|
+
afterPath.mtimeMs !== before.mtimeMs ||
|
|
473
|
+
(afterPath.dev !== undefined && before.dev !== undefined && afterPath.dev !== before.dev) ||
|
|
474
|
+
(afterPath.ino !== undefined && before.ino !== undefined && afterPath.ino !== before.ino)
|
|
475
|
+
) {
|
|
476
|
+
throw new Error(`Verification source input changed after read: ${markdownPathLabel(relativePath)}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
type: "file",
|
|
481
|
+
mode: before.mode & 0o7777,
|
|
482
|
+
size: before.size,
|
|
483
|
+
sha256: hashBuffer(content),
|
|
484
|
+
...(includeContent ? { content } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function readSafeSourceText(root, relativePath) {
|
|
489
|
+
const safePath = assertSafeSourceInput(root, relativePath);
|
|
490
|
+
const result = readRegularFile(root, safePath, { includeContent: true });
|
|
491
|
+
|
|
492
|
+
return result.content.toString("utf8");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function snapshotDigest(files) {
|
|
496
|
+
const stable = Object.entries(files)
|
|
497
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
498
|
+
.map(([relativePath, value]) => [relativePath, value]);
|
|
499
|
+
|
|
500
|
+
return hashBuffer(Buffer.from(JSON.stringify(stable), "utf8"));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function validateHead(head) {
|
|
504
|
+
if (head === undefined) {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (typeof head !== "string" || !SAFE_HEAD.test(head)) {
|
|
509
|
+
throw new Error("Verification HEAD identity is invalid");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return head;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function captureSourceSnapshot(root, options = {}) {
|
|
516
|
+
const resolvedRoot = canonicalSourceRoot(root);
|
|
517
|
+
const filesToRead = enumerateSourceFiles(resolvedRoot);
|
|
518
|
+
const ignored = normalizeIgnoredPaths(resolvedRoot, options.ignoredPaths);
|
|
519
|
+
const isIgnored = (relativePath) =>
|
|
520
|
+
[...ignored].some((ignoredPath) => relativePath === ignoredPath || relativePath.startsWith(`${ignoredPath}/`));
|
|
521
|
+
const ignoredCandidates = filesToRead.filter(isIgnored);
|
|
522
|
+
if (ignoredCandidates.length > 0) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`Verification source inventory contains ignored inputs: ${ignoredCandidates.map(markdownPathLabel).join(", ")}`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const files = {};
|
|
529
|
+
let totalBytes = 0;
|
|
530
|
+
for (const relativePath of filesToRead) {
|
|
531
|
+
const descriptor = readRegularFile(resolvedRoot, relativePath);
|
|
532
|
+
totalBytes += descriptor.size;
|
|
533
|
+
if (totalBytes > MAX_SOURCE_TOTAL_BYTES) {
|
|
534
|
+
throw new Error(`Verification source inventory exceeds ${MAX_SOURCE_TOTAL_BYTES} bytes`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
files[relativePath] = descriptor;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const repeatedFiles = enumerateSourceFiles(resolvedRoot);
|
|
541
|
+
const repeatedIgnored = repeatedFiles.filter(isIgnored);
|
|
542
|
+
if (
|
|
543
|
+
repeatedFiles.length !== filesToRead.length ||
|
|
544
|
+
repeatedFiles.some((relativePath, index) => relativePath !== filesToRead[index]) ||
|
|
545
|
+
repeatedIgnored.length > 0
|
|
546
|
+
) {
|
|
547
|
+
throw new Error("Verification source inventory changed during fingerprint");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
schema: VERIFICATION_SCHEMA,
|
|
552
|
+
root: resolvedRoot,
|
|
553
|
+
...(validateHead(options.head) === undefined ? {} : { head: options.head }),
|
|
554
|
+
files,
|
|
555
|
+
digest: snapshotDigest(files),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function descriptorEqual(left, right) {
|
|
560
|
+
return (
|
|
561
|
+
left?.type === right?.type &&
|
|
562
|
+
left?.mode === right?.mode &&
|
|
563
|
+
left?.size === right?.size &&
|
|
564
|
+
left?.sha256 === right?.sha256
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function validateSourceSnapshot(snapshot, root) {
|
|
569
|
+
if (
|
|
570
|
+
!snapshot ||
|
|
571
|
+
snapshot.schema !== VERIFICATION_SCHEMA ||
|
|
572
|
+
typeof snapshot.root !== "string" ||
|
|
573
|
+
!snapshot.files ||
|
|
574
|
+
typeof snapshot.files !== "object" ||
|
|
575
|
+
Array.isArray(snapshot.files) ||
|
|
576
|
+
typeof snapshot.digest !== "string"
|
|
577
|
+
) {
|
|
578
|
+
throw new Error("Verification source snapshot is malformed");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const expectedRoot = canonicalSourceRoot(root ?? snapshot.root);
|
|
582
|
+
if (snapshot.root !== expectedRoot) {
|
|
583
|
+
throw new Error("Verification source snapshot root changed");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const entries = Object.entries(snapshot.files);
|
|
587
|
+
if (entries.length === 0 || entries.length > MAX_SOURCE_FILES) {
|
|
588
|
+
throw new Error("Verification source snapshot file count is invalid");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const files = {};
|
|
592
|
+
let totalBytes = 0;
|
|
593
|
+
for (const [relativePath, descriptor] of entries) {
|
|
594
|
+
const safePath = assertRelativePath(relativePath);
|
|
595
|
+
const basename = safePath.split("/").at(-1);
|
|
596
|
+
if (isSensitiveFilename(basename) || safePath.split("/").some((part) => isExcludedDirectory(part))) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Verification source snapshot contains an excluded or sensitive path: ${markdownPathLabel(safePath)}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (
|
|
603
|
+
descriptor?.type !== "file" ||
|
|
604
|
+
!Number.isInteger(descriptor.mode) ||
|
|
605
|
+
descriptor.mode < 0 ||
|
|
606
|
+
descriptor.mode > 0o7777 ||
|
|
607
|
+
!Number.isInteger(descriptor.size) ||
|
|
608
|
+
descriptor.size < 0 ||
|
|
609
|
+
descriptor.size > MAX_SOURCE_FILE_BYTES ||
|
|
610
|
+
typeof descriptor.sha256 !== "string" ||
|
|
611
|
+
!/^[a-f0-9]{64}$/u.test(descriptor.sha256)
|
|
612
|
+
) {
|
|
613
|
+
throw new Error(`Verification source snapshot descriptor is invalid: ${markdownPathLabel(safePath)}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
totalBytes += descriptor.size;
|
|
617
|
+
if (totalBytes > MAX_SOURCE_TOTAL_BYTES) {
|
|
618
|
+
throw new Error("Verification source snapshot byte count is invalid");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
files[safePath] = { ...descriptor };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
for (const safePath of Object.keys(files)) {
|
|
625
|
+
if (!isKnownInventoryPath(safePath)) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`Verification source snapshot path is outside the closed source inventory: ${markdownPathLabel(safePath)}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (snapshot.digest !== snapshotDigest(files)) {
|
|
633
|
+
throw new Error("Verification source snapshot digest does not match its files");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
validateHead(snapshot.head);
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
schema: VERIFICATION_SCHEMA,
|
|
640
|
+
root: expectedRoot,
|
|
641
|
+
...(snapshot.head === undefined ? {} : { head: snapshot.head }),
|
|
642
|
+
files,
|
|
643
|
+
digest: snapshot.digest,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function compareSourceSnapshots(before, after) {
|
|
648
|
+
const left = validateSourceSnapshot(before);
|
|
649
|
+
const right = validateSourceSnapshot(after, left.root);
|
|
650
|
+
const candidates = new Set([...Object.keys(left.files), ...Object.keys(right.files)]);
|
|
651
|
+
const changed = [...candidates]
|
|
652
|
+
.filter((relativePath) => !descriptorEqual(left.files[relativePath], right.files[relativePath]))
|
|
653
|
+
.sort();
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
changed,
|
|
657
|
+
digestChanged: left.digest !== right.digest,
|
|
658
|
+
headChanged: left.head !== right.head,
|
|
659
|
+
indeterminate: false,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function sourceSnapshotPaths(snapshot) {
|
|
664
|
+
return Object.keys(validateSourceSnapshot(snapshot).files).sort();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function createVerificationReceipt({
|
|
668
|
+
before,
|
|
669
|
+
after,
|
|
670
|
+
registryDigest,
|
|
671
|
+
validatorDigest,
|
|
672
|
+
gates,
|
|
673
|
+
contractDigest,
|
|
674
|
+
gapId,
|
|
675
|
+
selectionId,
|
|
676
|
+
sourceRootSalt,
|
|
677
|
+
runtimeAt = new Date().toISOString(),
|
|
678
|
+
}) {
|
|
679
|
+
const comparison = compareSourceSnapshots(before, after);
|
|
680
|
+
if (comparison.digestChanged || comparison.headChanged || comparison.changed.length > 0) {
|
|
681
|
+
throw new Error("Cannot create verification receipt from changed source snapshots");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const validated = validateSourceSnapshot(after);
|
|
685
|
+
if (typeof registryDigest !== "string" || !/^[a-f0-9]{64}$/u.test(registryDigest)) {
|
|
686
|
+
throw new Error("Verification registry digest is invalid");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (typeof validatorDigest !== "string" || !/^[a-f0-9]{64}$/u.test(validatorDigest)) {
|
|
690
|
+
throw new Error("Verification validator digest is invalid");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!Array.isArray(gates) || gates.length < 1 || gates.length > MAX_RECEIPT_GATES) {
|
|
694
|
+
throw new Error(`Verification receipt requires 1-${MAX_RECEIPT_GATES} gates`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const normalizedGates = gates.map((gate) => {
|
|
698
|
+
if (
|
|
699
|
+
!gate ||
|
|
700
|
+
typeof gate.id !== "string" ||
|
|
701
|
+
!SAFE_GATE_ID.test(gate.id) ||
|
|
702
|
+
!Number.isInteger(gate.exitCode) ||
|
|
703
|
+
gate.exitCode < 0 ||
|
|
704
|
+
gate.exitCode > 255
|
|
705
|
+
) {
|
|
706
|
+
throw new Error("Verification receipt gate is invalid");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return { id: gate.id, exitCode: gate.exitCode };
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (typeof contractDigest !== "string" || !/^[a-f0-9]{64}$/u.test(contractDigest)) {
|
|
713
|
+
throw new Error("Verification contract digest is invalid");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const safeIdentity = (value, label) => {
|
|
717
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/u.test(value)) {
|
|
718
|
+
throw new Error(`Verification ${label} is invalid`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return value;
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const normalizedGapId = safeIdentity(gapId, "gap ID");
|
|
725
|
+
const normalizedSelectionId = safeIdentity(selectionId, "selection ID");
|
|
726
|
+
if (typeof sourceRootSalt !== "string" || sourceRootSalt.length < 16 || sourceRootSalt.length > 256) {
|
|
727
|
+
throw new Error("Verification source-root salt is invalid");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (typeof runtimeAt !== "string" || !Number.isFinite(Date.parse(runtimeAt))) {
|
|
731
|
+
throw new Error("Verification runtime timestamp is invalid");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
schema: VERIFICATION_SCHEMA,
|
|
736
|
+
sourceDigest: validated.digest,
|
|
737
|
+
registryDigest,
|
|
738
|
+
validatorDigest,
|
|
739
|
+
gates: normalizedGates,
|
|
740
|
+
runtimeAt,
|
|
741
|
+
contractDigest,
|
|
742
|
+
gapId: normalizedGapId,
|
|
743
|
+
selectionId: normalizedSelectionId,
|
|
744
|
+
sourceRootIdentity: saltedSourceRootIdentity(validated.root, sourceRootSalt),
|
|
745
|
+
runtime: {
|
|
746
|
+
node: process.version,
|
|
747
|
+
platform: process.platform,
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export function validateVerificationReceipt(receipt) {
|
|
753
|
+
if (
|
|
754
|
+
!receipt ||
|
|
755
|
+
receipt.schema !== VERIFICATION_SCHEMA ||
|
|
756
|
+
typeof receipt.sourceDigest !== "string" ||
|
|
757
|
+
!/^[a-f0-9]{64}$/u.test(receipt.sourceDigest) ||
|
|
758
|
+
typeof receipt.registryDigest !== "string" ||
|
|
759
|
+
!/^[a-f0-9]{64}$/u.test(receipt.registryDigest) ||
|
|
760
|
+
typeof receipt.validatorDigest !== "string" ||
|
|
761
|
+
!/^[a-f0-9]{64}$/u.test(receipt.validatorDigest) ||
|
|
762
|
+
!Array.isArray(receipt.gates) ||
|
|
763
|
+
receipt.gates.length < 1 ||
|
|
764
|
+
receipt.gates.length > MAX_RECEIPT_GATES ||
|
|
765
|
+
!receipt.gates.every(
|
|
766
|
+
(gate) =>
|
|
767
|
+
gate &&
|
|
768
|
+
typeof gate.id === "string" &&
|
|
769
|
+
SAFE_GATE_ID.test(gate.id) &&
|
|
770
|
+
Number.isInteger(gate.exitCode) &&
|
|
771
|
+
gate.exitCode >= 0 &&
|
|
772
|
+
gate.exitCode <= 255,
|
|
773
|
+
) ||
|
|
774
|
+
typeof receipt.runtimeAt !== "string" ||
|
|
775
|
+
!Number.isFinite(Date.parse(receipt.runtimeAt)) ||
|
|
776
|
+
typeof receipt.contractDigest !== "string" ||
|
|
777
|
+
!/^[a-f0-9]{64}$/u.test(receipt.contractDigest) ||
|
|
778
|
+
typeof receipt.gapId !== "string" ||
|
|
779
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/u.test(receipt.gapId) ||
|
|
780
|
+
typeof receipt.selectionId !== "string" ||
|
|
781
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/u.test(receipt.selectionId) ||
|
|
782
|
+
typeof receipt.sourceRootIdentity !== "string" ||
|
|
783
|
+
!/^[a-f0-9]{64}$/u.test(receipt.sourceRootIdentity) ||
|
|
784
|
+
typeof receipt.runtime !== "object" ||
|
|
785
|
+
receipt.runtime === null ||
|
|
786
|
+
typeof receipt.runtime.node !== "string" ||
|
|
787
|
+
!/^v\d+\.\d+\.\d+/.test(receipt.runtime.node) ||
|
|
788
|
+
typeof receipt.runtime.platform !== "string" ||
|
|
789
|
+
!/^[A-Za-z0-9._-]{1,40}$/u.test(receipt.runtime.platform)
|
|
790
|
+
) {
|
|
791
|
+
throw new Error("Verification receipt is malformed");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
schema: VERIFICATION_SCHEMA,
|
|
796
|
+
sourceDigest: receipt.sourceDigest,
|
|
797
|
+
registryDigest: receipt.registryDigest,
|
|
798
|
+
validatorDigest: receipt.validatorDigest,
|
|
799
|
+
gates: receipt.gates.map((gate) => ({ id: gate.id, exitCode: gate.exitCode })),
|
|
800
|
+
runtimeAt: receipt.runtimeAt,
|
|
801
|
+
contractDigest: receipt.contractDigest,
|
|
802
|
+
gapId: receipt.gapId,
|
|
803
|
+
selectionId: receipt.selectionId,
|
|
804
|
+
sourceRootIdentity: receipt.sourceRootIdentity,
|
|
805
|
+
runtime: {
|
|
806
|
+
node: receipt.runtime.node,
|
|
807
|
+
platform: receipt.runtime.platform,
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|