@hraness/kb 0.17.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.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,608 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants, type BigIntStats } from "node:fs";
3
+ import { lstat, open, opendir, realpath, type FileHandle } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ import {
7
+ CAPTURE_MANIFEST_FILENAME,
8
+ CAPTURE_SOURCE_EVIDENCE_PATH,
9
+ } from "./persist.js";
10
+
11
+ const MAX_MANIFEST_BYTES = 1 * 1_024 * 1_024;
12
+ const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1_024 * 1_024;
13
+ const MAX_DOCUMENT_BYTES = 256 * 1_024 * 1_024;
14
+ const DEFAULT_MAX_SOURCE_BYTES = 32 * 1_024 * 1_024;
15
+ const MAX_SOURCE_BYTES = 128 * 1_024 * 1_024;
16
+ const MAX_ASSETS = 10_000;
17
+ const MAX_LEGACY_DIRECTORY_ENTRIES = 1_000;
18
+ const DEFAULT_MAX_VERIFIED_ASSETS = 1_000;
19
+ const DEFAULT_MAX_ASSET_BYTES = 100 * 1_024 * 1_024;
20
+ const DEFAULT_MAX_TOTAL_ASSET_BYTES = 500 * 1_024 * 1_024;
21
+ const MAX_ASSET_BYTES = 8 * 1_024 ** 3;
22
+ const MAX_TOTAL_ASSET_BYTES = 8 * 1_024 ** 3;
23
+ const DEFAULT_ASSET_VERIFICATION_MS = 30_000;
24
+ const MAX_ASSET_VERIFICATION_MS = 5 * 60 * 1_000;
25
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
26
+
27
+ export type CaptureBundleIntegrity = "mismatch" | "unavailable" | "verified";
28
+
29
+ export type CaptureBundleInspection = {
30
+ readonly root: string;
31
+ readonly schemaVersion: 1 | 2 | 3 | 4;
32
+ readonly sourceUrl: string;
33
+ readonly canonicalUrl: string | null;
34
+ readonly status: string | null;
35
+ readonly capturedAt: string | null;
36
+ readonly document: {
37
+ readonly path: string;
38
+ readonly bytes: number;
39
+ readonly sha256: string;
40
+ readonly expectedBytes: number | null;
41
+ readonly expectedSha256: string | null;
42
+ readonly integrity: CaptureBundleIntegrity;
43
+ readonly markdown: string;
44
+ };
45
+ readonly assets: readonly {
46
+ readonly path: string;
47
+ readonly expectedBytes: number | null;
48
+ readonly expectedSha256: string | null;
49
+ readonly integrity: CaptureBundleIntegrity;
50
+ }[];
51
+ /** Source evidence is hostile, inert text and is omitted unless explicitly requested. */
52
+ readonly sourceHtml?: string;
53
+ };
54
+
55
+ export type CaptureBundleVerification = {
56
+ readonly ok: boolean;
57
+ readonly inspection: CaptureBundleInspection;
58
+ readonly issues: readonly {
59
+ readonly kind: "asset-integrity" | "document-integrity";
60
+ readonly path: string;
61
+ readonly message: string;
62
+ }[];
63
+ };
64
+
65
+ export type ReadCaptureBundleOptions = {
66
+ readonly includeSourceHtml?: boolean;
67
+ readonly verifyAssets?: boolean;
68
+ readonly maxDocumentBytes?: number;
69
+ readonly maxSourceHtmlBytes?: number;
70
+ readonly maxVerifiedAssets?: number;
71
+ readonly maxAssetBytes?: number;
72
+ readonly maxTotalAssetBytes?: number;
73
+ readonly maxAssetVerificationMs?: number;
74
+ };
75
+
76
+ type JsonObject = Readonly<Record<string, unknown>>;
77
+
78
+ function object(value: unknown, label: string): JsonObject {
79
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
80
+ throw new Error(`${label} must be an object`);
81
+ }
82
+ return value as JsonObject;
83
+ }
84
+
85
+ function optionalString(value: unknown): string | null {
86
+ return typeof value === "string" ? value : null;
87
+ }
88
+
89
+ function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number {
90
+ const selected = value ?? fallback;
91
+ if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) {
92
+ throw new RangeError(`${label} must be an integer from 1 through ${maximum}`);
93
+ }
94
+ return selected;
95
+ }
96
+
97
+ function confinedRelativePath(value: unknown, label: string): string {
98
+ if (typeof value !== "string" || value.length === 0 || value.length > 4_096 || value.includes("\0")) {
99
+ throw new Error(`${label} must be a bounded non-empty path`);
100
+ }
101
+ if (isAbsolute(value) || value.includes("\\")) throw new Error(`${label} must be a relative POSIX path`);
102
+ const segments = value.split("/");
103
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
104
+ throw new Error(`${label} contains an unsafe segment`);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ type SafeBundleRoot = {
110
+ readonly path: string;
111
+ readonly handle: FileHandle;
112
+ readonly device: bigint;
113
+ readonly inode: bigint;
114
+ };
115
+
116
+ class MissingCaptureAssetError extends Error {}
117
+
118
+ function isMissingPathError(error: unknown): boolean {
119
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
120
+ }
121
+
122
+ function assertAssetVerificationDeadline(deadline: number | undefined): void {
123
+ if (deadline !== undefined && performance.now() >= deadline) {
124
+ throw new Error("capture asset verification exceeded its time budget");
125
+ }
126
+ }
127
+
128
+ function confined(root: SafeBundleRoot, relativePath: string, label: string): string {
129
+ const path = resolve(root.path, relativePath);
130
+ const lexical = relative(root.path, path);
131
+ if (lexical === "" || lexical === ".." || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
132
+ throw new Error(`${label} escapes the capture bundle`);
133
+ }
134
+ return path;
135
+ }
136
+
137
+ async function safeDirectory(pathInput: string): Promise<SafeBundleRoot> {
138
+ if (pathInput.trim() === "") throw new Error("capture bundle path is required");
139
+ const path = resolve(pathInput);
140
+ const canonical = await realpath(path);
141
+ if (canonical !== path) throw new Error("capture bundle path must be canonical and cannot traverse a symbolic link");
142
+ const stats = await lstat(path, { bigint: true });
143
+ if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error("capture bundle path must be a real directory");
144
+ if (dirname(path) === path) throw new Error("refusing a filesystem root as a capture bundle");
145
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
146
+ try {
147
+ const opened = await handle.stat({ bigint: true });
148
+ if (!opened.isDirectory() || opened.dev !== stats.dev || opened.ino !== stats.ino) {
149
+ throw new Error("capture bundle root changed while it was opened");
150
+ }
151
+ return { path, handle, device: opened.dev, inode: opened.ino };
152
+ } catch (error) {
153
+ await handle.close();
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ async function assertBundleRoot(root: SafeBundleRoot, deadline?: number): Promise<void> {
159
+ assertAssetVerificationDeadline(deadline);
160
+ const [opened, named, canonical] = await Promise.all([
161
+ root.handle.stat({ bigint: true }),
162
+ lstat(root.path, { bigint: true }),
163
+ realpath(root.path),
164
+ ]);
165
+ assertAssetVerificationDeadline(deadline);
166
+ if (
167
+ canonical !== root.path
168
+ || !opened.isDirectory()
169
+ || !named.isDirectory()
170
+ || named.isSymbolicLink()
171
+ || opened.dev !== root.device
172
+ || opened.ino !== root.inode
173
+ || named.dev !== root.device
174
+ || named.ino !== root.inode
175
+ ) throw new Error("capture bundle root changed during inspection");
176
+ }
177
+
178
+ async function assertAncestorChain(
179
+ root: SafeBundleRoot,
180
+ relativePath: string,
181
+ label: string,
182
+ deadline?: number,
183
+ ): Promise<void> {
184
+ await assertBundleRoot(root, deadline);
185
+ const segments = confinedRelativePath(relativePath, label).split("/");
186
+ let current = root.path;
187
+ for (const segment of segments.slice(0, -1)) {
188
+ current = resolve(current, segment);
189
+ assertAssetVerificationDeadline(deadline);
190
+ const metadata = await lstat(current, { bigint: true });
191
+ assertAssetVerificationDeadline(deadline);
192
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
193
+ throw new Error(`${label} must not traverse a linked or non-directory ancestor`);
194
+ }
195
+ const canonical = await realpath(current);
196
+ assertAssetVerificationDeadline(deadline);
197
+ if (canonical !== current) {
198
+ throw new Error(`${label} must not traverse a filesystem alias`);
199
+ }
200
+ }
201
+ await assertBundleRoot(root, deadline);
202
+ }
203
+
204
+ async function withRegularFile<T>(
205
+ root: SafeBundleRoot,
206
+ relativePath: string,
207
+ maximumBytes: number,
208
+ label: string,
209
+ read: (handle: FileHandle, size: number) => Promise<T>,
210
+ deadline?: number,
211
+ missingError?: Error,
212
+ ): Promise<T> {
213
+ await assertAncestorChain(root, relativePath, label, deadline);
214
+ const path = confined(root, relativePath, label);
215
+ assertAssetVerificationDeadline(deadline);
216
+ let before: BigIntStats;
217
+ try {
218
+ before = await lstat(path, { bigint: true });
219
+ } catch (error) {
220
+ assertAssetVerificationDeadline(deadline);
221
+ if (missingError !== undefined && isMissingPathError(error)) throw missingError;
222
+ throw error;
223
+ }
224
+ assertAssetVerificationDeadline(deadline);
225
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n) {
226
+ throw new Error(`${label} must be a regular single-link file`);
227
+ }
228
+ if (before.size > BigInt(maximumBytes)) throw new Error(`${label} exceeds ${maximumBytes} bytes`);
229
+ const canonical = await realpath(path);
230
+ assertAssetVerificationDeadline(deadline);
231
+ if (canonical !== path) throw new Error(`${label} must not traverse a filesystem alias`);
232
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
233
+ assertAssetVerificationDeadline(deadline);
234
+ try {
235
+ const opened = await handle.stat({ bigint: true });
236
+ assertAssetVerificationDeadline(deadline);
237
+ if (
238
+ !opened.isFile()
239
+ || opened.nlink !== 1n
240
+ || opened.dev !== before.dev
241
+ || opened.ino !== before.ino
242
+ || opened.size !== before.size
243
+ ) throw new Error(`${label} changed while it was opened`);
244
+ await assertAncestorChain(root, relativePath, label, deadline);
245
+ const result = await read(handle, Number(opened.size));
246
+ assertAssetVerificationDeadline(deadline);
247
+ const after = await handle.stat({ bigint: true });
248
+ assertAssetVerificationDeadline(deadline);
249
+ const named = await lstat(path, { bigint: true });
250
+ assertAssetVerificationDeadline(deadline);
251
+ await assertAncestorChain(root, relativePath, label, deadline);
252
+ const finalCanonical = await realpath(path);
253
+ assertAssetVerificationDeadline(deadline);
254
+ if (
255
+ after.dev !== before.dev
256
+ || after.ino !== before.ino
257
+ || after.size !== before.size
258
+ || after.mtimeNs !== before.mtimeNs
259
+ || after.nlink !== 1n
260
+ || named.dev !== before.dev
261
+ || named.ino !== before.ino
262
+ || named.size !== before.size
263
+ || named.mtimeNs !== before.mtimeNs
264
+ || named.nlink !== 1n
265
+ || named.isSymbolicLink()
266
+ || finalCanonical !== path
267
+ ) throw new Error(`${label} changed while it was read`);
268
+ return result;
269
+ } finally {
270
+ await handle.close();
271
+ assertAssetVerificationDeadline(deadline);
272
+ }
273
+ }
274
+
275
+ async function readRegularFile(
276
+ root: SafeBundleRoot,
277
+ relativePath: string,
278
+ maximumBytes: number,
279
+ label: string,
280
+ ): Promise<Buffer> {
281
+ return withRegularFile(root, relativePath, maximumBytes, label, async (handle, size) => {
282
+ const bytes = Buffer.alloc(size);
283
+ let offset = 0;
284
+ while (offset < bytes.byteLength) {
285
+ const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
286
+ if (result.bytesRead === 0) throw new Error(`${label} changed while it was read`);
287
+ offset += result.bytesRead;
288
+ }
289
+ return bytes;
290
+ });
291
+ }
292
+
293
+ async function hashRegularFile(
294
+ root: SafeBundleRoot,
295
+ relativePath: string,
296
+ maximumBytes: number,
297
+ deadline: number,
298
+ label: string,
299
+ ): Promise<{ readonly bytes: number; readonly sha256: string }> {
300
+ assertAssetVerificationDeadline(deadline);
301
+ await assertBundleRoot(root, deadline);
302
+ assertAssetVerificationDeadline(deadline);
303
+ const path = confined(root, relativePath, label);
304
+ assertAssetVerificationDeadline(deadline);
305
+ try {
306
+ await lstat(path, { bigint: true });
307
+ } catch (error) {
308
+ assertAssetVerificationDeadline(deadline);
309
+ if (isMissingPathError(error)) {
310
+ await assertBundleRoot(root, deadline);
311
+ throw new MissingCaptureAssetError(`capture asset ${relativePath} is missing`);
312
+ }
313
+ throw error;
314
+ }
315
+ assertAssetVerificationDeadline(deadline);
316
+ return withRegularFile(root, relativePath, maximumBytes, label, async (handle, size) => {
317
+ assertAssetVerificationDeadline(deadline);
318
+ const hash = createHash("sha256");
319
+ const chunk = Buffer.allocUnsafe(Math.min(1 * 1_024 * 1_024, Math.max(1, size)));
320
+ let offset = 0;
321
+ while (offset < size) {
322
+ assertAssetVerificationDeadline(deadline);
323
+ const result = await handle.read(chunk, 0, Math.min(chunk.byteLength, size - offset), offset);
324
+ assertAssetVerificationDeadline(deadline);
325
+ if (result.bytesRead === 0) throw new Error(`${label} changed while it was read`);
326
+ hash.update(chunk.subarray(0, result.bytesRead));
327
+ offset += result.bytesRead;
328
+ }
329
+ assertAssetVerificationDeadline(deadline);
330
+ return { bytes: size, sha256: hash.digest("hex") };
331
+ }, deadline, new MissingCaptureAssetError(`capture asset ${relativePath} is missing`));
332
+ }
333
+
334
+ function digest(bytes: Uint8Array): string {
335
+ return createHash("sha256").update(bytes).digest("hex");
336
+ }
337
+
338
+ function manifestVersion(manifest: JsonObject): 1 | 2 | 3 | 4 {
339
+ const version = manifest.schemaVersion;
340
+ if (version === 1 || version === 2 || version === 3 || version === 4) return version;
341
+ throw new Error("capture manifest schemaVersion must be 1, 2, 3, or 4");
342
+ }
343
+
344
+ async function legacyMarkdownPath(root: SafeBundleRoot): Promise<string> {
345
+ await assertBundleRoot(root);
346
+ const expected = `${basename(root.path)}.md`;
347
+ const directory = await opendir(root.path);
348
+ const markdown: string[] = [];
349
+ let observedEntries = 0;
350
+ try {
351
+ while (true) {
352
+ const entry = await directory.read();
353
+ if (entry === null) break;
354
+ observedEntries += 1;
355
+ if (observedEntries > MAX_LEGACY_DIRECTORY_ENTRIES) {
356
+ throw new Error(`legacy capture bundle contains more than ${MAX_LEGACY_DIRECTORY_ENTRIES} top-level entries`);
357
+ }
358
+ if (entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith(".md")) {
359
+ markdown.push(entry.name);
360
+ }
361
+ }
362
+ } finally {
363
+ await directory.close();
364
+ }
365
+ await assertBundleRoot(root);
366
+ if (markdown.includes(expected)) return expected;
367
+ if (markdown.length === 1 && markdown[0] !== undefined) return markdown[0];
368
+ throw new Error("legacy capture bundle must contain exactly one identifiable top-level Markdown document");
369
+ }
370
+
371
+ function v4Document(manifest: JsonObject): {
372
+ readonly path: string;
373
+ readonly bytes: number;
374
+ readonly sha256: string;
375
+ } {
376
+ const document = object(manifest.document, "capture manifest document");
377
+ const path = confinedRelativePath(document.path, "capture manifest document.path");
378
+ if (!path.endsWith(".md")) throw new Error("capture manifest document.path must name Markdown");
379
+ if (!Number.isSafeInteger(document.bytes) || (document.bytes as number) < 0) {
380
+ throw new Error("capture manifest document.bytes must be a non-negative safe integer");
381
+ }
382
+ if (typeof document.sha256 !== "string" || !SHA256_PATTERN.test(document.sha256)) {
383
+ throw new Error("capture manifest document.sha256 must be a lowercase SHA-256 digest");
384
+ }
385
+ return { path, bytes: document.bytes as number, sha256: document.sha256 };
386
+ }
387
+
388
+ function manifestAssets(manifest: JsonObject): readonly {
389
+ readonly path: string;
390
+ readonly bytes: number | null;
391
+ readonly sha256: string | null;
392
+ }[] {
393
+ if (manifest.assets === undefined) return [];
394
+ if (!Array.isArray(manifest.assets) || manifest.assets.length > MAX_ASSETS) {
395
+ throw new Error(`capture manifest assets must contain at most ${MAX_ASSETS} entries`);
396
+ }
397
+ const seen = new Set<string>();
398
+ return Object.freeze(manifest.assets.map((raw, index) => {
399
+ const asset = object(raw, `capture manifest assets[${index}]`);
400
+ const path = confinedRelativePath(asset.path, `capture manifest assets[${index}].path`);
401
+ if (seen.has(path)) throw new Error(`capture manifest repeats asset path ${path}`);
402
+ seen.add(path);
403
+ const bytes = Number.isSafeInteger(asset.bytes) && (asset.bytes as number) >= 0
404
+ ? asset.bytes as number
405
+ : null;
406
+ const sha256 = typeof asset.sha256 === "string" && SHA256_PATTERN.test(asset.sha256)
407
+ ? asset.sha256
408
+ : null;
409
+ return { path, bytes, sha256 };
410
+ }));
411
+ }
412
+
413
+ function sourceHtmlPath(manifest: JsonObject): string | null {
414
+ if (manifest.evidence === undefined) return null;
415
+ const evidence = object(manifest.evidence, "capture manifest evidence");
416
+ const raw = evidence.sourceHtmlPath;
417
+ if (raw === null || raw === undefined) return null;
418
+ const path = confinedRelativePath(raw, "capture manifest evidence.sourceHtmlPath");
419
+ if (path !== CAPTURE_SOURCE_EVIDENCE_PATH) {
420
+ throw new Error("capture manifest source HTML path is not the owned evidence path");
421
+ }
422
+ return path;
423
+ }
424
+
425
+ /** Read a stored capture as hostile, inert data and verify every available digest. */
426
+ export async function readCaptureBundle(
427
+ path: string,
428
+ options: ReadCaptureBundleOptions = {},
429
+ ): Promise<CaptureBundleInspection> {
430
+ const root = await safeDirectory(path);
431
+ try {
432
+ const manifestBytes = await readRegularFile(
433
+ root,
434
+ CAPTURE_MANIFEST_FILENAME,
435
+ MAX_MANIFEST_BYTES,
436
+ "capture manifest",
437
+ );
438
+ let parsed: unknown;
439
+ try {
440
+ parsed = JSON.parse(manifestBytes.toString("utf8")) as unknown;
441
+ } catch {
442
+ throw new Error("capture manifest is not valid JSON");
443
+ }
444
+ const manifest = object(parsed, "capture manifest");
445
+ const schemaVersion = manifestVersion(manifest);
446
+ if (typeof manifest.sourceUrl !== "string") throw new Error("capture manifest sourceUrl is required");
447
+ const expectedDocument = schemaVersion === 4 ? v4Document(manifest) : null;
448
+ const documentPath = expectedDocument?.path ?? await legacyMarkdownPath(root);
449
+ const documentBytes = await readRegularFile(
450
+ root,
451
+ documentPath,
452
+ boundedInteger(options.maxDocumentBytes, DEFAULT_MAX_DOCUMENT_BYTES, MAX_DOCUMENT_BYTES, "document byte limit"),
453
+ "capture document",
454
+ );
455
+ const documentSha256 = digest(documentBytes);
456
+ const documentIntegrity: CaptureBundleIntegrity = expectedDocument === null
457
+ ? "unavailable"
458
+ : expectedDocument.bytes === documentBytes.byteLength && expectedDocument.sha256 === documentSha256
459
+ ? "verified"
460
+ : "mismatch";
461
+
462
+ const configuredAssets = manifestAssets(manifest);
463
+ const maximumVerifiedAssets = boundedInteger(
464
+ options.maxVerifiedAssets,
465
+ DEFAULT_MAX_VERIFIED_ASSETS,
466
+ MAX_ASSETS,
467
+ "verified asset count limit",
468
+ );
469
+ if (options.verifyAssets && configuredAssets.length > maximumVerifiedAssets) {
470
+ throw new Error(`capture asset verification exceeds the ${maximumVerifiedAssets}-file limit`);
471
+ }
472
+ const maximumAssetBytes = boundedInteger(
473
+ options.maxAssetBytes,
474
+ DEFAULT_MAX_ASSET_BYTES,
475
+ MAX_ASSET_BYTES,
476
+ "asset byte limit",
477
+ );
478
+ const maximumTotalAssetBytes = boundedInteger(
479
+ options.maxTotalAssetBytes,
480
+ DEFAULT_MAX_TOTAL_ASSET_BYTES,
481
+ MAX_TOTAL_ASSET_BYTES,
482
+ "total asset byte limit",
483
+ );
484
+ if (maximumTotalAssetBytes < maximumAssetBytes) {
485
+ throw new RangeError("total asset byte limit cannot be smaller than the per-asset byte limit");
486
+ }
487
+ const verificationDeadline = performance.now() + boundedInteger(
488
+ options.maxAssetVerificationMs,
489
+ DEFAULT_ASSET_VERIFICATION_MS,
490
+ MAX_ASSET_VERIFICATION_MS,
491
+ "asset verification time limit",
492
+ );
493
+ let verifiedAssetBytes = 0;
494
+ const assets = [] as Array<CaptureBundleInspection["assets"][number]>;
495
+ for (const asset of configuredAssets) {
496
+ if (!options.verifyAssets) {
497
+ assets.push(Object.freeze({
498
+ path: asset.path,
499
+ expectedBytes: asset.bytes,
500
+ expectedSha256: asset.sha256,
501
+ integrity: "unavailable",
502
+ }));
503
+ continue;
504
+ }
505
+ assertAssetVerificationDeadline(verificationDeadline);
506
+ const remainingBytes = maximumTotalAssetBytes - verifiedAssetBytes;
507
+ if (remainingBytes < 0) throw new Error("capture asset verification exceeds its total byte limit");
508
+ let verified: Awaited<ReturnType<typeof hashRegularFile>>;
509
+ try {
510
+ verified = await hashRegularFile(
511
+ root,
512
+ asset.path,
513
+ Math.min(maximumAssetBytes, remainingBytes),
514
+ verificationDeadline,
515
+ `capture asset ${asset.path}`,
516
+ );
517
+ } catch (error) {
518
+ assertAssetVerificationDeadline(verificationDeadline);
519
+ if (!(error instanceof MissingCaptureAssetError)) throw error;
520
+ assets.push(Object.freeze({
521
+ path: asset.path,
522
+ expectedBytes: asset.bytes,
523
+ expectedSha256: asset.sha256,
524
+ integrity: "mismatch",
525
+ }));
526
+ continue;
527
+ }
528
+ verifiedAssetBytes += verified.bytes;
529
+ const integrity = asset.bytes !== null
530
+ && asset.sha256 !== null
531
+ && asset.bytes === verified.bytes
532
+ && asset.sha256 === verified.sha256
533
+ ? "verified" as const
534
+ : "mismatch" as const;
535
+ assets.push(Object.freeze({
536
+ path: asset.path,
537
+ expectedBytes: asset.bytes,
538
+ expectedSha256: asset.sha256,
539
+ integrity,
540
+ }));
541
+ assertAssetVerificationDeadline(verificationDeadline);
542
+ }
543
+
544
+ let sourceHtml: string | undefined;
545
+ if (options.includeSourceHtml) {
546
+ const evidencePath = sourceHtmlPath(manifest);
547
+ if (evidencePath !== null) {
548
+ sourceHtml = (await readRegularFile(
549
+ root,
550
+ evidencePath,
551
+ boundedInteger(options.maxSourceHtmlBytes, DEFAULT_MAX_SOURCE_BYTES, MAX_SOURCE_BYTES, "source HTML byte limit"),
552
+ "capture source HTML",
553
+ )).toString("utf8");
554
+ }
555
+ }
556
+
557
+ await assertBundleRoot(root);
558
+ return Object.freeze({
559
+ root: root.path,
560
+ schemaVersion,
561
+ sourceUrl: manifest.sourceUrl,
562
+ canonicalUrl: optionalString(manifest.canonicalUrl),
563
+ status: optionalString(manifest.status),
564
+ capturedAt: optionalString(manifest.capturedAt),
565
+ document: Object.freeze({
566
+ path: documentPath,
567
+ bytes: documentBytes.byteLength,
568
+ sha256: documentSha256,
569
+ expectedBytes: expectedDocument?.bytes ?? null,
570
+ expectedSha256: expectedDocument?.sha256 ?? null,
571
+ integrity: documentIntegrity,
572
+ markdown: documentBytes.toString("utf8"),
573
+ }),
574
+ assets: Object.freeze(assets),
575
+ ...(sourceHtml === undefined ? {} : { sourceHtml }),
576
+ });
577
+ } finally {
578
+ await root.handle.close();
579
+ }
580
+ }
581
+
582
+ /** Return integrity issues without treating content tampering as a parser failure. */
583
+ export async function verifyCaptureBundle(
584
+ path: string,
585
+ options: ReadCaptureBundleOptions = {},
586
+ ): Promise<CaptureBundleVerification> {
587
+ const inspection = await readCaptureBundle(path, options);
588
+ const issues: CaptureBundleVerification["issues"][number][] = [];
589
+ if (inspection.document.integrity !== "verified") {
590
+ issues.push(Object.freeze({
591
+ kind: "document-integrity",
592
+ path: inspection.document.path,
593
+ message: inspection.document.integrity === "mismatch"
594
+ ? "Stored Markdown bytes do not match the v4 capture manifest."
595
+ : "Legacy capture manifest does not contain an authoritative Markdown digest.",
596
+ }));
597
+ }
598
+ for (const asset of inspection.assets) {
599
+ if (asset.integrity === "mismatch") {
600
+ issues.push(Object.freeze({
601
+ kind: "asset-integrity",
602
+ path: asset.path,
603
+ message: "Stored asset bytes do not match the capture manifest.",
604
+ }));
605
+ }
606
+ }
607
+ return Object.freeze({ ok: issues.length === 0, inspection, issues: Object.freeze(issues) });
608
+ }