blun-king-cli 9.1.564 → 9.1.566

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 (88) hide show
  1. package/LIESMICH.txt +2 -2
  2. package/README.md +1 -1
  3. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  4. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  5. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  6. package/agent-spine-plugin/.mcp.json +8 -8
  7. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  8. package/agent-spine-plugin/LICENSE +186 -186
  9. package/agent-spine-plugin/README.md +406 -0
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  12. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  13. package/agent-spine-plugin/bin/agentspine.js +7 -7
  14. package/agent-spine-plugin/blun.plugin.json +33 -33
  15. package/agent-spine-plugin/hooks/codex.json +47 -47
  16. package/agent-spine-plugin/hooks/hooks.json +106 -106
  17. package/agent-spine-plugin/hooks/version.json +5 -5
  18. package/agent-spine-plugin/package.json +69 -69
  19. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  20. package/agent-spine-plugin/skill/SKILL.md +76 -76
  21. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  22. package/agent-spine-plugin/src/cli.js +1488 -1442
  23. package/agent-spine-plugin/src/hook.js +882 -861
  24. package/agent-spine-plugin/src/index.js +94 -93
  25. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  26. package/agent-spine-plugin/src/lib/attention.js +755 -755
  27. package/agent-spine-plugin/src/lib/audit.js +351 -342
  28. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  29. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  30. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  31. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  32. package/agent-spine-plugin/src/lib/context.js +154 -154
  33. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  34. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  35. package/agent-spine-plugin/src/lib/documents.js +217 -217
  36. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  37. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  38. package/agent-spine-plugin/src/lib/gateway-runtime.js +1623 -760
  39. package/agent-spine-plugin/src/lib/graph.js +337 -337
  40. package/agent-spine-plugin/src/lib/hook-audit.js +29 -29
  41. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  42. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  43. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  44. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  45. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  46. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  47. package/agent-spine-plugin/src/lib/paths.js +109 -109
  48. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  49. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  50. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  51. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  52. package/agent-spine-plugin/src/lib/selfstarter.js +911 -866
  53. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  54. package/agent-spine-plugin/src/lib/source-roots.js +530 -498
  55. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  56. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  57. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  58. package/agent-spine-plugin/src/mcp.js +597 -572
  59. package/agent-spine-plugin/src/version.js +1 -1
  60. package/agent-spine-plugin/src/worker.js +210 -195
  61. package/bin/active-steer-priority-policy.cjs +24 -0
  62. package/bin/launcher-runtime.js +8 -1
  63. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  64. package/bin/native-runtime-cache.cjs +76 -0
  65. package/bin/thinking-activity-status-policy.cjs +132 -0
  66. package/bin/thinking-only-guard.cjs +75 -0
  67. package/bin/tool-call-loop-policy.cjs +53 -0
  68. package/bin/turn-thinking-policy.cjs +25 -1
  69. package/blun.mjs +556 -63
  70. package/package.json +4 -1
  71. package/standard-skills/translate-native/LICENSE +21 -21
  72. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  73. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  74. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  75. package/standard-skills/translate-native/references/structured-content.md +72 -72
  76. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  77. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  78. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  79. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  80. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  81. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  82. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  83. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  84. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  85. package/standard-tools/language-guard/check_diacritics.py +353 -353
  86. package/standard-tools/language-guard/guard_service_client.py +264 -264
  87. package/standard-tools/language-guard/language_quality.py +377 -377
  88. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,392 +1,392 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { lookup as dnsLookup } from "node:dns/promises";
3
- import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
- import { request as httpsRequest } from "node:https";
5
- import { BlockList, isIP } from "node:net";
6
- import { tmpdir } from "node:os";
7
- import { basename, dirname, join, resolve } from "node:path";
8
- import { verifyEnvelope } from "./authentication.js";
9
- import { buildCatalog } from "./catalog.js";
10
- import { isInside } from "./paths.js";
11
- import { pullShared, readDirectoryExchange, validateSharedEvent } from "./sharing.js";
12
-
13
- const SCHEMA = "agentspine.https-snapshot/v1";
14
- const CONFIRMATION = "local-share-confirmed";
15
- const MAX_EVENTS = 2000;
16
- const MAX_SNAPSHOT_BYTES = 21 * 1024 * 1024;
17
- const MAX_TOKEN_BYTES = 8192;
18
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
19
- const DIGEST_RE = /^[a-f0-9]{64}$/;
20
- const TOKEN_ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
21
-
22
- const blockedAddresses = new BlockList();
23
- const publicIpv6 = new BlockList();
24
- publicIpv6.addSubnet("2000::", 3, "ipv6");
25
- for (const [network, prefix] of [
26
- ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
27
- ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
28
- ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
29
- ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]
30
- ]) blockedAddresses.addSubnet(network, prefix, "ipv4");
31
- for (const [network, prefix] of [
32
- ["::", 128], ["::1", 128], ["64:ff9b::", 96],
33
- ["100::", 64], ["2001::", 32], ["2001:2::", 48], ["2001:10::", 28],
34
- ["2001:20::", 28], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20],
35
- ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]
36
- ]) blockedAddresses.addSubnet(network, prefix, "ipv6");
37
-
38
- function canonical(value) {
39
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
40
- if (value && typeof value === "object") {
41
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
42
- }
43
- return JSON.stringify(value);
44
- }
45
-
46
- function digest(value) {
47
- return createHash("sha256").update(canonical(value)).digest("hex");
48
- }
49
-
50
- function exactKeys(value, expected) {
51
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
52
- const actual = Object.keys(value).sort();
53
- const wanted = [...expected].sort();
54
- return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
55
- }
56
-
57
- function validDate(value) {
58
- return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
59
- }
60
-
61
- function withoutDigest(value) {
62
- const { digest: _digest, ...body } = value;
63
- return body;
64
- }
65
-
66
- function eventId(document) {
67
- const payload = document?.schema === "agentspine.signed-envelope/v1" ? document.payload : null;
68
- return payload?.id;
69
- }
70
-
71
- function eventFilename(id) {
72
- return `${createHash("sha256").update(id).digest("hex")}.json`;
73
- }
74
-
75
- async function prospectiveCanonicalPath(path) {
76
- let ancestor = path;
77
- const missing = [];
78
- while (true) {
79
- try {
80
- const canonicalAncestor = await realpath(ancestor);
81
- return resolve(canonicalAncestor, ...missing.reverse());
82
- } catch (error) {
83
- if (error.code !== "ENOENT") throw error;
84
- const parent = dirname(ancestor);
85
- if (parent === ancestor) throw error;
86
- missing.push(basename(ancestor));
87
- ancestor = parent;
88
- }
89
- }
90
- }
91
-
92
- export function validateHttpsEndpoint(value) {
93
- let endpoint;
94
- try {
95
- endpoint = new URL(value);
96
- } catch {
97
- throw new Error("HTTPS snapshot URL is invalid");
98
- }
99
- if (endpoint.protocol !== "https:") throw new Error("HTTPS snapshots require an https:// URL");
100
- if (endpoint.username || endpoint.password) throw new Error("credentials must not be embedded in an HTTPS snapshot URL");
101
- if (endpoint.search || endpoint.hash) throw new Error("HTTPS snapshot URLs cannot contain a query or fragment");
102
- if (!endpoint.hostname || endpoint.pathname.endsWith("/")) {
103
- throw new Error("HTTPS snapshot URL must identify one explicit JSON resource");
104
- }
105
- return endpoint;
106
- }
107
-
108
- function addressBlocked(address) {
109
- const family = isIP(address);
110
- if (!family) throw new Error(`DNS returned an invalid address: ${address}`);
111
- if (family === 6 && !publicIpv6.check(address, "ipv6")) return true;
112
- return blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
113
- }
114
-
115
- async function within(promise, milliseconds, message) {
116
- let timer;
117
- try {
118
- return await Promise.race([
119
- promise,
120
- new Promise((_resolve, reject) => {
121
- timer = setTimeout(() => reject(new Error(message)), milliseconds);
122
- })
123
- ]);
124
- } finally {
125
- clearTimeout(timer);
126
- }
127
- }
128
-
129
- export async function resolveHttpsEndpoint(endpoint, {
130
- allowPrivateNetwork = false, lookup = dnsLookup
131
- } = {}) {
132
- const parsed = endpoint instanceof URL ? endpoint : validateHttpsEndpoint(endpoint);
133
- const lookupHostname = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
134
- ? parsed.hostname.slice(1, -1) : parsed.hostname;
135
- const literalFamily = isIP(lookupHostname);
136
- const answers = literalFamily
137
- ? [{ address: lookupHostname, family: literalFamily }]
138
- : await lookup(lookupHostname, { all: true, verbatim: true });
139
- if (!Array.isArray(answers) || answers.length === 0) throw new Error("HTTPS snapshot host did not resolve");
140
- for (const answer of answers) {
141
- if (!answer || isIP(answer.address) !== Number(answer.family)) throw new Error("HTTPS snapshot DNS result is invalid");
142
- if (!allowPrivateNetwork && addressBlocked(answer.address)) {
143
- throw new Error("HTTPS snapshot host resolves to a private, local, reserved, or documentation address");
144
- }
145
- }
146
- return {
147
- endpoint: parsed, lookupHostname,
148
- addresses: answers.map((answer) => ({ address: answer.address, family: Number(answer.family) }))
149
- };
150
- }
151
-
152
- function bearerToken(tokenEnv, environment) {
153
- if (tokenEnv === null || tokenEnv === undefined) return null;
154
- if (!TOKEN_ENV_RE.test(tokenEnv)) throw new Error("tokenEnv must be an uppercase environment variable name");
155
- const value = environment[tokenEnv];
156
- if (typeof value !== "string" || !value) throw new Error(`HTTPS snapshot token environment variable is missing: ${tokenEnv}`);
157
- if (Buffer.byteLength(value) > MAX_TOKEN_BYTES || /[\0-\x1f\x7f]/.test(value)) {
158
- throw new Error("HTTPS snapshot bearer token is invalid");
159
- }
160
- return value;
161
- }
162
-
163
- export async function fetchHttpsSnapshot({
164
- url, tokenEnv = null, timeoutMs = 10000, allowPrivateNetwork = false,
165
- confirmation = null, environment = process.env, lookup = dnsLookup, request = httpsRequest
166
- }) {
167
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
168
- throw new Error("HTTPS snapshot timeout must be between 1000 and 30000 milliseconds");
169
- }
170
- if (allowPrivateNetwork && confirmation !== CONFIRMATION) {
171
- throw new Error("private-network HTTPS access requires explicit local owner confirmation");
172
- }
173
- const resolved = await within(
174
- resolveHttpsEndpoint(validateHttpsEndpoint(url), { allowPrivateNetwork, lookup }),
175
- timeoutMs,
176
- "HTTPS snapshot DNS resolution timed out"
177
- );
178
- const token = bearerToken(tokenEnv, environment);
179
- const selected = resolved.addresses[0];
180
- const headers = {
181
- accept: "application/json",
182
- "accept-encoding": "identity",
183
- "user-agent": "AgentSpine/0.1 HTTPS snapshot transport"
184
- };
185
- if (token) headers.authorization = `Bearer ${token}`;
186
- const body = await new Promise((resolvePromise, rejectPromise) => {
187
- let deadline;
188
- const reject = (error) => {
189
- clearTimeout(deadline);
190
- rejectPromise(error);
191
- };
192
- const resolve = (value) => {
193
- clearTimeout(deadline);
194
- resolvePromise(value);
195
- };
196
- const requestOptions = {
197
- protocol: "https:", hostname: resolved.lookupHostname,
198
- port: resolved.endpoint.port || 443,
199
- path: resolved.endpoint.pathname, method: "GET", headers,
200
- agent: false, autoSelectFamily: false,
201
- servername: isIP(resolved.lookupHostname) ? undefined : resolved.lookupHostname,
202
- lookup: (_hostname, options, callback) => {
203
- const family = typeof options === "object" && options.family ? Number(options.family) : 0;
204
- const usable = family ? resolved.addresses.find((item) => item.family === family) : selected;
205
- if (!usable) return callback(new Error("no vetted DNS address matches the requested family"));
206
- return callback(null, usable.address, usable.family);
207
- }
208
- };
209
- const active = request(requestOptions, (response) => {
210
- const status = Number(response.statusCode || 0);
211
- if (status >= 300 && status < 400) {
212
- response.resume();
213
- reject(new Error("HTTPS snapshot redirects are not followed"));
214
- return;
215
- }
216
- if (status !== 200) {
217
- response.resume();
218
- reject(new Error(`HTTPS snapshot request failed with status ${status}`));
219
- return;
220
- }
221
- const contentType = String(response.headers["content-type"] || "").toLowerCase();
222
- if (!/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/.test(contentType)) {
223
- response.resume();
224
- reject(new Error("HTTPS snapshot response must use an application/json content type"));
225
- return;
226
- }
227
- const encoding = String(response.headers["content-encoding"] || "identity").toLowerCase();
228
- if (encoding !== "identity") {
229
- response.resume();
230
- reject(new Error("compressed HTTPS snapshots are rejected to keep byte limits exact"));
231
- return;
232
- }
233
- const declared = Number(response.headers["content-length"] || 0);
234
- if (declared && (!Number.isSafeInteger(declared) || declared > MAX_SNAPSHOT_BYTES)) {
235
- response.resume();
236
- reject(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
237
- return;
238
- }
239
- const chunks = [];
240
- let bytes = 0;
241
- response.on("data", (chunk) => {
242
- bytes += chunk.length;
243
- if (bytes > MAX_SNAPSHOT_BYTES) {
244
- active.destroy(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
245
- return;
246
- }
247
- chunks.push(chunk);
248
- });
249
- response.on("end", () => resolve(Buffer.concat(chunks)));
250
- response.on("error", reject);
251
- });
252
- active.setTimeout(timeoutMs, () => active.destroy(new Error("HTTPS snapshot request timed out")));
253
- deadline = setTimeout(() => active.destroy(new Error("HTTPS snapshot request timed out")), timeoutMs);
254
- active.on("error", reject);
255
- active.end();
256
- });
257
- let snapshot;
258
- try {
259
- snapshot = JSON.parse(body.toString("utf8"));
260
- } catch {
261
- throw new Error("HTTPS snapshot response is not valid JSON");
262
- }
263
- return {
264
- snapshot: validateHttpsSnapshot(snapshot),
265
- endpoint: `${resolved.endpoint.origin}${resolved.endpoint.pathname}`,
266
- authenticatedRequest: Boolean(token)
267
- };
268
- }
269
-
270
- export function validateHttpsSnapshot(snapshot) {
271
- if (!exactKeys(snapshot, [
272
- "schema", "snapshotId", "scopeId", "adapterId", "generatedAt",
273
- "manifest", "events", "authority", "digest"
274
- ]) || snapshot.schema !== SCHEMA || !ID_RE.test(snapshot.snapshotId || "")
275
- || !ID_RE.test(snapshot.scopeId || "") || !ID_RE.test(snapshot.adapterId || "")
276
- || !validDate(snapshot.generatedAt) || snapshot.authority !== "context-only"
277
- || !DIGEST_RE.test(snapshot.digest || "") || digest(withoutDigest(snapshot)) !== snapshot.digest
278
- || !Array.isArray(snapshot.events) || snapshot.events.length > MAX_EVENTS) {
279
- throw new Error("HTTPS snapshot is invalid or has failed its integrity check");
280
- }
281
- if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_SNAPSHOT_BYTES) {
282
- throw new Error("HTTPS snapshot exceeds the 21 MiB validation limit");
283
- }
284
- const verifiedManifest = verifyEnvelope(snapshot.manifest, "manifest");
285
- if (verifiedManifest.payload?.scopeId !== snapshot.scopeId
286
- || verifiedManifest.payload?.adapterId !== snapshot.adapterId
287
- || verifiedManifest.payload?.adapter !== "directory") {
288
- throw new Error("HTTPS snapshot metadata does not match its signed manifest");
289
- }
290
- const seen = new Map();
291
- for (const document of snapshot.events) {
292
- const verified = verifyEnvelope(document, "event");
293
- const event = validateSharedEvent(verified.payload, snapshot.scopeId);
294
- const previous = seen.get(event.id);
295
- if (previous && previous !== event.digest) throw new Error(`HTTPS snapshot event ID collision: ${event.id}`);
296
- if (previous) throw new Error(`HTTPS snapshot contains duplicate event: ${event.id}`);
297
- seen.set(event.id, event.digest);
298
- }
299
- return snapshot;
300
- }
301
-
302
- export async function buildHttpsSnapshot({
303
- root = process.cwd(), directory, snapshotId = `snapshot:${randomUUID()}`, now = new Date()
304
- }) {
305
- if (!ID_RE.test(snapshotId || "")) throw new Error("snapshotId must be a stable, whitespace-free identifier");
306
- const exchange = await readDirectoryExchange({ root, directory, requireAuthenticated: true });
307
- const generatedAt = new Date(now).toISOString();
308
- if (!validDate(generatedAt)) throw new Error("now must be a valid date");
309
- const body = {
310
- schema: SCHEMA, snapshotId, scopeId: exchange.scopeId, adapterId: exchange.adapterId,
311
- generatedAt, manifest: exchange.manifest, events: exchange.events,
312
- authority: "context-only"
313
- };
314
- return validateHttpsSnapshot({ ...body, digest: digest(body) });
315
- }
316
-
317
- export async function exportHttpsSnapshot({
318
- root = process.cwd(), directory, output, snapshotId = `snapshot:${randomUUID()}`,
319
- confirmation, now = new Date()
320
- }) {
321
- if (confirmation !== CONFIRMATION) throw new Error("HTTPS snapshot export requires explicit local owner confirmation");
322
- if (!output || typeof output !== "string") throw new Error("HTTPS snapshot output path is required");
323
- const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
324
- const catalog = await buildCatalog(root);
325
- const target = resolve(output);
326
- const prospectiveTarget = await prospectiveCanonicalPath(target);
327
- if (isInside(catalog.root, prospectiveTarget)) {
328
- throw new Error("HTTPS snapshot output must remain outside the scanned project");
329
- }
330
- let parent = dirname(target);
331
- await mkdir(parent, { recursive: true, mode: 0o700 });
332
- parent = await realpath(parent);
333
- if (isInside(catalog.root, parent)) {
334
- throw new Error("HTTPS snapshot output must remain outside the scanned project");
335
- }
336
- try {
337
- await lstat(target);
338
- throw new Error("HTTPS snapshot output already exists; choose a new path");
339
- } catch (error) {
340
- if (error.code !== "ENOENT") throw error;
341
- }
342
- const content = `${JSON.stringify(snapshot, null, 2)}\n`;
343
- if (Buffer.byteLength(content) > MAX_SNAPSHOT_BYTES) throw new Error("HTTPS snapshot exceeds the 21 MiB export limit");
344
- const handle = await open(target, "wx", 0o600);
345
- try {
346
- await handle.writeFile(content, "utf8");
347
- await handle.sync();
348
- } finally {
349
- await handle.close();
350
- }
351
- return {
352
- created: true, output: target, snapshotId: snapshot.snapshotId,
353
- scopeId: snapshot.scopeId, adapterId: snapshot.adapterId,
354
- events: snapshot.events.length, digest: snapshot.digest,
355
- authority: "context-only"
356
- };
357
- }
358
-
359
- export async function importHttpsSnapshot({ root = process.cwd(), snapshot, now = new Date() }) {
360
- const validated = validateHttpsSnapshot(structuredClone(snapshot));
361
- const directory = await mkdtemp(join(tmpdir(), "agentspine-https-snapshot-"));
362
- try {
363
- const eventsDirectory = join(directory, "events");
364
- await mkdir(eventsDirectory, { mode: 0o700 });
365
- await writeFile(
366
- join(directory, ".agentspine-exchange.json"),
367
- `${JSON.stringify(validated.manifest, null, 2)}\n`, { mode: 0o600 }
368
- );
369
- for (const document of validated.events) {
370
- const id = eventId(document);
371
- await writeFile(join(eventsDirectory, eventFilename(id)), `${JSON.stringify(document, null, 2)}\n`, {
372
- mode: 0o600, flag: "wx"
373
- });
374
- }
375
- const result = await pullShared({ root, directory, requireAuthenticated: true, now });
376
- return {
377
- ...result, transport: "https-snapshot", snapshotId: validated.snapshotId,
378
- snapshotDigest: validated.digest, authority: "context-only"
379
- };
380
- } finally {
381
- await rm(directory, { recursive: true, force: true });
382
- }
383
- }
384
-
385
- export async function pullHttpsSnapshot(options) {
386
- const fetched = await fetchHttpsSnapshot(options);
387
- const imported = await importHttpsSnapshot({ root: options.root, snapshot: fetched.snapshot, now: options.now });
388
- return {
389
- ...imported, endpoint: fetched.endpoint,
390
- authenticatedRequest: fetched.authenticatedRequest
391
- };
392
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lookup as dnsLookup } from "node:dns/promises";
3
+ import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
+ import { request as httpsRequest } from "node:https";
5
+ import { BlockList, isIP } from "node:net";
6
+ import { tmpdir } from "node:os";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import { verifyEnvelope } from "./authentication.js";
9
+ import { buildCatalog } from "./catalog.js";
10
+ import { isInside } from "./paths.js";
11
+ import { pullShared, readDirectoryExchange, validateSharedEvent } from "./sharing.js";
12
+
13
+ const SCHEMA = "agentspine.https-snapshot/v1";
14
+ const CONFIRMATION = "local-share-confirmed";
15
+ const MAX_EVENTS = 2000;
16
+ const MAX_SNAPSHOT_BYTES = 21 * 1024 * 1024;
17
+ const MAX_TOKEN_BYTES = 8192;
18
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
19
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
20
+ const TOKEN_ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
21
+
22
+ const blockedAddresses = new BlockList();
23
+ const publicIpv6 = new BlockList();
24
+ publicIpv6.addSubnet("2000::", 3, "ipv6");
25
+ for (const [network, prefix] of [
26
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
27
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
28
+ ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
29
+ ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]
30
+ ]) blockedAddresses.addSubnet(network, prefix, "ipv4");
31
+ for (const [network, prefix] of [
32
+ ["::", 128], ["::1", 128], ["64:ff9b::", 96],
33
+ ["100::", 64], ["2001::", 32], ["2001:2::", 48], ["2001:10::", 28],
34
+ ["2001:20::", 28], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20],
35
+ ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]
36
+ ]) blockedAddresses.addSubnet(network, prefix, "ipv6");
37
+
38
+ function canonical(value) {
39
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
40
+ if (value && typeof value === "object") {
41
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
42
+ }
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ function digest(value) {
47
+ return createHash("sha256").update(canonical(value)).digest("hex");
48
+ }
49
+
50
+ function exactKeys(value, expected) {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
52
+ const actual = Object.keys(value).sort();
53
+ const wanted = [...expected].sort();
54
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
55
+ }
56
+
57
+ function validDate(value) {
58
+ return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
59
+ }
60
+
61
+ function withoutDigest(value) {
62
+ const { digest: _digest, ...body } = value;
63
+ return body;
64
+ }
65
+
66
+ function eventId(document) {
67
+ const payload = document?.schema === "agentspine.signed-envelope/v1" ? document.payload : null;
68
+ return payload?.id;
69
+ }
70
+
71
+ function eventFilename(id) {
72
+ return `${createHash("sha256").update(id).digest("hex")}.json`;
73
+ }
74
+
75
+ async function prospectiveCanonicalPath(path) {
76
+ let ancestor = path;
77
+ const missing = [];
78
+ while (true) {
79
+ try {
80
+ const canonicalAncestor = await realpath(ancestor);
81
+ return resolve(canonicalAncestor, ...missing.reverse());
82
+ } catch (error) {
83
+ if (error.code !== "ENOENT") throw error;
84
+ const parent = dirname(ancestor);
85
+ if (parent === ancestor) throw error;
86
+ missing.push(basename(ancestor));
87
+ ancestor = parent;
88
+ }
89
+ }
90
+ }
91
+
92
+ export function validateHttpsEndpoint(value) {
93
+ let endpoint;
94
+ try {
95
+ endpoint = new URL(value);
96
+ } catch {
97
+ throw new Error("HTTPS snapshot URL is invalid");
98
+ }
99
+ if (endpoint.protocol !== "https:") throw new Error("HTTPS snapshots require an https:// URL");
100
+ if (endpoint.username || endpoint.password) throw new Error("credentials must not be embedded in an HTTPS snapshot URL");
101
+ if (endpoint.search || endpoint.hash) throw new Error("HTTPS snapshot URLs cannot contain a query or fragment");
102
+ if (!endpoint.hostname || endpoint.pathname.endsWith("/")) {
103
+ throw new Error("HTTPS snapshot URL must identify one explicit JSON resource");
104
+ }
105
+ return endpoint;
106
+ }
107
+
108
+ function addressBlocked(address) {
109
+ const family = isIP(address);
110
+ if (!family) throw new Error(`DNS returned an invalid address: ${address}`);
111
+ if (family === 6 && !publicIpv6.check(address, "ipv6")) return true;
112
+ return blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
113
+ }
114
+
115
+ async function within(promise, milliseconds, message) {
116
+ let timer;
117
+ try {
118
+ return await Promise.race([
119
+ promise,
120
+ new Promise((_resolve, reject) => {
121
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
122
+ })
123
+ ]);
124
+ } finally {
125
+ clearTimeout(timer);
126
+ }
127
+ }
128
+
129
+ export async function resolveHttpsEndpoint(endpoint, {
130
+ allowPrivateNetwork = false, lookup = dnsLookup
131
+ } = {}) {
132
+ const parsed = endpoint instanceof URL ? endpoint : validateHttpsEndpoint(endpoint);
133
+ const lookupHostname = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
134
+ ? parsed.hostname.slice(1, -1) : parsed.hostname;
135
+ const literalFamily = isIP(lookupHostname);
136
+ const answers = literalFamily
137
+ ? [{ address: lookupHostname, family: literalFamily }]
138
+ : await lookup(lookupHostname, { all: true, verbatim: true });
139
+ if (!Array.isArray(answers) || answers.length === 0) throw new Error("HTTPS snapshot host did not resolve");
140
+ for (const answer of answers) {
141
+ if (!answer || isIP(answer.address) !== Number(answer.family)) throw new Error("HTTPS snapshot DNS result is invalid");
142
+ if (!allowPrivateNetwork && addressBlocked(answer.address)) {
143
+ throw new Error("HTTPS snapshot host resolves to a private, local, reserved, or documentation address");
144
+ }
145
+ }
146
+ return {
147
+ endpoint: parsed, lookupHostname,
148
+ addresses: answers.map((answer) => ({ address: answer.address, family: Number(answer.family) }))
149
+ };
150
+ }
151
+
152
+ function bearerToken(tokenEnv, environment) {
153
+ if (tokenEnv === null || tokenEnv === undefined) return null;
154
+ if (!TOKEN_ENV_RE.test(tokenEnv)) throw new Error("tokenEnv must be an uppercase environment variable name");
155
+ const value = environment[tokenEnv];
156
+ if (typeof value !== "string" || !value) throw new Error(`HTTPS snapshot token environment variable is missing: ${tokenEnv}`);
157
+ if (Buffer.byteLength(value) > MAX_TOKEN_BYTES || /[\0-\x1f\x7f]/.test(value)) {
158
+ throw new Error("HTTPS snapshot bearer token is invalid");
159
+ }
160
+ return value;
161
+ }
162
+
163
+ export async function fetchHttpsSnapshot({
164
+ url, tokenEnv = null, timeoutMs = 10000, allowPrivateNetwork = false,
165
+ confirmation = null, environment = process.env, lookup = dnsLookup, request = httpsRequest
166
+ }) {
167
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
168
+ throw new Error("HTTPS snapshot timeout must be between 1000 and 30000 milliseconds");
169
+ }
170
+ if (allowPrivateNetwork && confirmation !== CONFIRMATION) {
171
+ throw new Error("private-network HTTPS access requires explicit local owner confirmation");
172
+ }
173
+ const resolved = await within(
174
+ resolveHttpsEndpoint(validateHttpsEndpoint(url), { allowPrivateNetwork, lookup }),
175
+ timeoutMs,
176
+ "HTTPS snapshot DNS resolution timed out"
177
+ );
178
+ const token = bearerToken(tokenEnv, environment);
179
+ const selected = resolved.addresses[0];
180
+ const headers = {
181
+ accept: "application/json",
182
+ "accept-encoding": "identity",
183
+ "user-agent": "AgentSpine/0.1 HTTPS snapshot transport"
184
+ };
185
+ if (token) headers.authorization = `Bearer ${token}`;
186
+ const body = await new Promise((resolvePromise, rejectPromise) => {
187
+ let deadline;
188
+ const reject = (error) => {
189
+ clearTimeout(deadline);
190
+ rejectPromise(error);
191
+ };
192
+ const resolve = (value) => {
193
+ clearTimeout(deadline);
194
+ resolvePromise(value);
195
+ };
196
+ const requestOptions = {
197
+ protocol: "https:", hostname: resolved.lookupHostname,
198
+ port: resolved.endpoint.port || 443,
199
+ path: resolved.endpoint.pathname, method: "GET", headers,
200
+ agent: false, autoSelectFamily: false,
201
+ servername: isIP(resolved.lookupHostname) ? undefined : resolved.lookupHostname,
202
+ lookup: (_hostname, options, callback) => {
203
+ const family = typeof options === "object" && options.family ? Number(options.family) : 0;
204
+ const usable = family ? resolved.addresses.find((item) => item.family === family) : selected;
205
+ if (!usable) return callback(new Error("no vetted DNS address matches the requested family"));
206
+ return callback(null, usable.address, usable.family);
207
+ }
208
+ };
209
+ const active = request(requestOptions, (response) => {
210
+ const status = Number(response.statusCode || 0);
211
+ if (status >= 300 && status < 400) {
212
+ response.resume();
213
+ reject(new Error("HTTPS snapshot redirects are not followed"));
214
+ return;
215
+ }
216
+ if (status !== 200) {
217
+ response.resume();
218
+ reject(new Error(`HTTPS snapshot request failed with status ${status}`));
219
+ return;
220
+ }
221
+ const contentType = String(response.headers["content-type"] || "").toLowerCase();
222
+ if (!/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/.test(contentType)) {
223
+ response.resume();
224
+ reject(new Error("HTTPS snapshot response must use an application/json content type"));
225
+ return;
226
+ }
227
+ const encoding = String(response.headers["content-encoding"] || "identity").toLowerCase();
228
+ if (encoding !== "identity") {
229
+ response.resume();
230
+ reject(new Error("compressed HTTPS snapshots are rejected to keep byte limits exact"));
231
+ return;
232
+ }
233
+ const declared = Number(response.headers["content-length"] || 0);
234
+ if (declared && (!Number.isSafeInteger(declared) || declared > MAX_SNAPSHOT_BYTES)) {
235
+ response.resume();
236
+ reject(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
237
+ return;
238
+ }
239
+ const chunks = [];
240
+ let bytes = 0;
241
+ response.on("data", (chunk) => {
242
+ bytes += chunk.length;
243
+ if (bytes > MAX_SNAPSHOT_BYTES) {
244
+ active.destroy(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
245
+ return;
246
+ }
247
+ chunks.push(chunk);
248
+ });
249
+ response.on("end", () => resolve(Buffer.concat(chunks)));
250
+ response.on("error", reject);
251
+ });
252
+ active.setTimeout(timeoutMs, () => active.destroy(new Error("HTTPS snapshot request timed out")));
253
+ deadline = setTimeout(() => active.destroy(new Error("HTTPS snapshot request timed out")), timeoutMs);
254
+ active.on("error", reject);
255
+ active.end();
256
+ });
257
+ let snapshot;
258
+ try {
259
+ snapshot = JSON.parse(body.toString("utf8"));
260
+ } catch {
261
+ throw new Error("HTTPS snapshot response is not valid JSON");
262
+ }
263
+ return {
264
+ snapshot: validateHttpsSnapshot(snapshot),
265
+ endpoint: `${resolved.endpoint.origin}${resolved.endpoint.pathname}`,
266
+ authenticatedRequest: Boolean(token)
267
+ };
268
+ }
269
+
270
+ export function validateHttpsSnapshot(snapshot) {
271
+ if (!exactKeys(snapshot, [
272
+ "schema", "snapshotId", "scopeId", "adapterId", "generatedAt",
273
+ "manifest", "events", "authority", "digest"
274
+ ]) || snapshot.schema !== SCHEMA || !ID_RE.test(snapshot.snapshotId || "")
275
+ || !ID_RE.test(snapshot.scopeId || "") || !ID_RE.test(snapshot.adapterId || "")
276
+ || !validDate(snapshot.generatedAt) || snapshot.authority !== "context-only"
277
+ || !DIGEST_RE.test(snapshot.digest || "") || digest(withoutDigest(snapshot)) !== snapshot.digest
278
+ || !Array.isArray(snapshot.events) || snapshot.events.length > MAX_EVENTS) {
279
+ throw new Error("HTTPS snapshot is invalid or has failed its integrity check");
280
+ }
281
+ if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_SNAPSHOT_BYTES) {
282
+ throw new Error("HTTPS snapshot exceeds the 21 MiB validation limit");
283
+ }
284
+ const verifiedManifest = verifyEnvelope(snapshot.manifest, "manifest");
285
+ if (verifiedManifest.payload?.scopeId !== snapshot.scopeId
286
+ || verifiedManifest.payload?.adapterId !== snapshot.adapterId
287
+ || verifiedManifest.payload?.adapter !== "directory") {
288
+ throw new Error("HTTPS snapshot metadata does not match its signed manifest");
289
+ }
290
+ const seen = new Map();
291
+ for (const document of snapshot.events) {
292
+ const verified = verifyEnvelope(document, "event");
293
+ const event = validateSharedEvent(verified.payload, snapshot.scopeId);
294
+ const previous = seen.get(event.id);
295
+ if (previous && previous !== event.digest) throw new Error(`HTTPS snapshot event ID collision: ${event.id}`);
296
+ if (previous) throw new Error(`HTTPS snapshot contains duplicate event: ${event.id}`);
297
+ seen.set(event.id, event.digest);
298
+ }
299
+ return snapshot;
300
+ }
301
+
302
+ export async function buildHttpsSnapshot({
303
+ root = process.cwd(), directory, snapshotId = `snapshot:${randomUUID()}`, now = new Date()
304
+ }) {
305
+ if (!ID_RE.test(snapshotId || "")) throw new Error("snapshotId must be a stable, whitespace-free identifier");
306
+ const exchange = await readDirectoryExchange({ root, directory, requireAuthenticated: true });
307
+ const generatedAt = new Date(now).toISOString();
308
+ if (!validDate(generatedAt)) throw new Error("now must be a valid date");
309
+ const body = {
310
+ schema: SCHEMA, snapshotId, scopeId: exchange.scopeId, adapterId: exchange.adapterId,
311
+ generatedAt, manifest: exchange.manifest, events: exchange.events,
312
+ authority: "context-only"
313
+ };
314
+ return validateHttpsSnapshot({ ...body, digest: digest(body) });
315
+ }
316
+
317
+ export async function exportHttpsSnapshot({
318
+ root = process.cwd(), directory, output, snapshotId = `snapshot:${randomUUID()}`,
319
+ confirmation, now = new Date()
320
+ }) {
321
+ if (confirmation !== CONFIRMATION) throw new Error("HTTPS snapshot export requires explicit local owner confirmation");
322
+ if (!output || typeof output !== "string") throw new Error("HTTPS snapshot output path is required");
323
+ const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
324
+ const catalog = await buildCatalog(root);
325
+ const target = resolve(output);
326
+ const prospectiveTarget = await prospectiveCanonicalPath(target);
327
+ if (isInside(catalog.root, prospectiveTarget)) {
328
+ throw new Error("HTTPS snapshot output must remain outside the scanned project");
329
+ }
330
+ let parent = dirname(target);
331
+ await mkdir(parent, { recursive: true, mode: 0o700 });
332
+ parent = await realpath(parent);
333
+ if (isInside(catalog.root, parent)) {
334
+ throw new Error("HTTPS snapshot output must remain outside the scanned project");
335
+ }
336
+ try {
337
+ await lstat(target);
338
+ throw new Error("HTTPS snapshot output already exists; choose a new path");
339
+ } catch (error) {
340
+ if (error.code !== "ENOENT") throw error;
341
+ }
342
+ const content = `${JSON.stringify(snapshot, null, 2)}\n`;
343
+ if (Buffer.byteLength(content) > MAX_SNAPSHOT_BYTES) throw new Error("HTTPS snapshot exceeds the 21 MiB export limit");
344
+ const handle = await open(target, "wx", 0o600);
345
+ try {
346
+ await handle.writeFile(content, "utf8");
347
+ await handle.sync();
348
+ } finally {
349
+ await handle.close();
350
+ }
351
+ return {
352
+ created: true, output: target, snapshotId: snapshot.snapshotId,
353
+ scopeId: snapshot.scopeId, adapterId: snapshot.adapterId,
354
+ events: snapshot.events.length, digest: snapshot.digest,
355
+ authority: "context-only"
356
+ };
357
+ }
358
+
359
+ export async function importHttpsSnapshot({ root = process.cwd(), snapshot, now = new Date() }) {
360
+ const validated = validateHttpsSnapshot(structuredClone(snapshot));
361
+ const directory = await mkdtemp(join(tmpdir(), "agentspine-https-snapshot-"));
362
+ try {
363
+ const eventsDirectory = join(directory, "events");
364
+ await mkdir(eventsDirectory, { mode: 0o700 });
365
+ await writeFile(
366
+ join(directory, ".agentspine-exchange.json"),
367
+ `${JSON.stringify(validated.manifest, null, 2)}\n`, { mode: 0o600 }
368
+ );
369
+ for (const document of validated.events) {
370
+ const id = eventId(document);
371
+ await writeFile(join(eventsDirectory, eventFilename(id)), `${JSON.stringify(document, null, 2)}\n`, {
372
+ mode: 0o600, flag: "wx"
373
+ });
374
+ }
375
+ const result = await pullShared({ root, directory, requireAuthenticated: true, now });
376
+ return {
377
+ ...result, transport: "https-snapshot", snapshotId: validated.snapshotId,
378
+ snapshotDigest: validated.digest, authority: "context-only"
379
+ };
380
+ } finally {
381
+ await rm(directory, { recursive: true, force: true });
382
+ }
383
+ }
384
+
385
+ export async function pullHttpsSnapshot(options) {
386
+ const fetched = await fetchHttpsSnapshot(options);
387
+ const imported = await importHttpsSnapshot({ root: options.root, snapshot: fetched.snapshot, now: options.now });
388
+ return {
389
+ ...imported, endpoint: fetched.endpoint,
390
+ authenticatedRequest: fetched.authenticatedRequest
391
+ };
392
+ }