blun-king-cli 9.1.563 → 9.1.565

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 (85) hide show
  1. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  2. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  3. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  4. package/agent-spine-plugin/.mcp.json +8 -8
  5. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  6. package/agent-spine-plugin/LICENSE +186 -186
  7. package/agent-spine-plugin/README.md +394 -0
  8. package/agent-spine-plugin/SECURITY.md +47 -0
  9. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  10. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  11. package/agent-spine-plugin/bin/agentspine.js +7 -7
  12. package/agent-spine-plugin/blun.plugin.json +33 -33
  13. package/agent-spine-plugin/hooks/codex.json +47 -47
  14. package/agent-spine-plugin/hooks/hooks.json +106 -106
  15. package/agent-spine-plugin/hooks/version.json +5 -5
  16. package/agent-spine-plugin/package.json +69 -69
  17. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  18. package/agent-spine-plugin/skill/SKILL.md +76 -76
  19. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  20. package/agent-spine-plugin/src/cli.js +1488 -1442
  21. package/agent-spine-plugin/src/hook.js +886 -812
  22. package/agent-spine-plugin/src/index.js +93 -93
  23. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  24. package/agent-spine-plugin/src/lib/attention.js +755 -755
  25. package/agent-spine-plugin/src/lib/audit.js +351 -342
  26. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  27. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  28. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  29. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  30. package/agent-spine-plugin/src/lib/context.js +154 -154
  31. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  32. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  33. package/agent-spine-plugin/src/lib/documents.js +217 -217
  34. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  35. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  36. package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
  37. package/agent-spine-plugin/src/lib/graph.js +337 -337
  38. package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
  39. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  40. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  41. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  42. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  43. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  44. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  45. package/agent-spine-plugin/src/lib/paths.js +109 -109
  46. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  47. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  48. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  49. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  50. package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
  51. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  52. package/agent-spine-plugin/src/lib/source-roots.js +529 -482
  53. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  54. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  55. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  56. package/agent-spine-plugin/src/mcp.js +597 -572
  57. package/agent-spine-plugin/src/version.js +1 -1
  58. package/agent-spine-plugin/src/worker.js +202 -195
  59. package/bin/active-steer-priority-policy.cjs +24 -0
  60. package/bin/launcher-runtime.js +8 -1
  61. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  62. package/bin/thinking-activity-status-policy.cjs +132 -0
  63. package/bin/thinking-only-guard.cjs +75 -0
  64. package/bin/tool-call-loop-policy.cjs +53 -0
  65. package/bin/turn-thinking-policy.cjs +25 -1
  66. package/blun.mjs +554 -64
  67. package/package.json +4 -1
  68. package/standard-skills/translate-native/LICENSE +21 -21
  69. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  70. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  71. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  72. package/standard-skills/translate-native/references/structured-content.md +72 -72
  73. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  74. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  75. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  76. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  77. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  78. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  79. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  80. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  81. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  82. package/standard-tools/language-guard/check_diacritics.py +353 -353
  83. package/standard-tools/language-guard/guard_service_client.py +264 -264
  84. package/standard-tools/language-guard/language_quality.py +377 -377
  85. 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
+ }