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,515 +1,515 @@
1
- import {
2
- createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID,
3
- sign as cryptoSign, verify as cryptoVerify
4
- } from "node:crypto";
5
- import {
6
- chmod, lstat, mkdir, open, readFile, stat, unlink, writeFile
7
- } from "node:fs/promises";
8
- import { dirname, join, resolve } from "node:path";
9
- import { buildCatalog } from "./catalog.js";
10
- import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
11
- import { projectId, projectStateDir, statePathIsScanExcluded, stateRoot } from "./paths.js";
12
-
13
- const AUTH_CONFIRMATION = "local-share-confirmed";
14
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
15
- const DIGEST_RE = /^[a-f0-9]{64}$/;
16
- const SIGNATURE_RE = /^[A-Za-z0-9+/]{80,96}={0,2}$/;
17
- const MAX_AUTH_BYTES = 2 * 1024 * 1024;
18
- const MAX_PUBLIC_IDENTITY_BYTES = 16 * 1024;
19
- const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
20
-
21
- function canonical(value) {
22
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
23
- if (value && typeof value === "object") {
24
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
25
- }
26
- return JSON.stringify(value);
27
- }
28
-
29
- function digest(value) {
30
- return createHash("sha256").update(canonical(value)).digest("hex");
31
- }
32
-
33
- function exactKeys(value, expected) {
34
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
35
- const actual = Object.keys(value).sort();
36
- const wanted = [...expected].sort();
37
- return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
38
- }
39
-
40
- function validDate(value) {
41
- return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
42
- }
43
-
44
- function isoDate(value, field = "date") {
45
- const parsed = value instanceof Date ? value : new Date(value);
46
- if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
47
- return parsed.toISOString();
48
- }
49
-
50
- function requireConfirmation(confirmation) {
51
- if (confirmation !== AUTH_CONFIRMATION) throw new Error("signer and trust changes require explicit local owner confirmation");
52
- }
53
-
54
- function emptyRegistry() {
55
- return { schema: "agentspine.signers/v1", signers: [], history: [] };
56
- }
57
-
58
- function emptyTrust(root) {
59
- return { schema: "agentspine.sharing-trust/v1", root, records: [], history: [] };
60
- }
61
-
62
- function publicIdentityBody({ signerId, publicKey, createdAt }) {
63
- const keyBytes = Buffer.from(publicKey, "base64");
64
- return {
65
- schema: "agentspine.signer/v1",
66
- signerId,
67
- keyId: `ed25519:${createHash("sha256").update(keyBytes).digest("hex")}`,
68
- algorithm: "Ed25519",
69
- publicKey,
70
- createdAt,
71
- authority: "context-only"
72
- };
73
- }
74
-
75
- export function validatePublicIdentity(identity) {
76
- if (!identity || typeof identity !== "object" || Array.isArray(identity)
77
- || !exactKeys(identity, ["schema", "signerId", "keyId", "algorithm", "publicKey", "createdAt", "authority", "digest"])
78
- || identity.schema !== "agentspine.signer/v1" || !ID_RE.test(identity.signerId || "")
79
- || !ID_RE.test(identity.keyId || "") || identity.algorithm !== "Ed25519"
80
- || typeof identity.publicKey !== "string" || identity.publicKey.length > 1024
81
- || !validDate(identity.createdAt) || identity.authority !== "context-only"
82
- || !DIGEST_RE.test(identity.digest || "")) {
83
- throw new Error("public signing identity is invalid");
84
- }
85
- let key;
86
- try {
87
- key = createPublicKey({ key: Buffer.from(identity.publicKey, "base64"), format: "der", type: "spki" });
88
- } catch {
89
- throw new Error("public signing identity contains an invalid Ed25519 key");
90
- }
91
- if (key.asymmetricKeyType !== "ed25519") throw new Error("public signing identity must use Ed25519");
92
- const body = { ...identity };
93
- delete body.digest;
94
- const expected = publicIdentityBody({ signerId: identity.signerId, publicKey: identity.publicKey, createdAt: identity.createdAt });
95
- if (identity.keyId !== expected.keyId || identity.digest !== digest(body)) {
96
- throw new Error("public signing identity failed its fingerprint or integrity check");
97
- }
98
- return identity;
99
- }
100
-
101
- function registryFindings(registry) {
102
- const findings = [];
103
- if (!exactKeys(registry, ["schema", "signers", "history"])
104
- || registry.schema !== "agentspine.signers/v1" || !Array.isArray(registry.signers)
105
- || !Array.isArray(registry.history)) return ["invalid-signer-registry"];
106
- const ids = new Set();
107
- const keys = new Set();
108
- for (const record of registry.signers) {
109
- try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-signer:${record?.signerId || "unknown"}`); }
110
- if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "createdAt", "authority"])
111
- || record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
112
- || record.createdAt !== record.publicIdentity?.createdAt || record.authority !== "context-only") {
113
- findings.push(`invalid-signer-record:${record?.signerId || "unknown"}`);
114
- }
115
- if (ids.has(record.signerId)) findings.push(`duplicate-signer:${record.signerId}`);
116
- if (keys.has(record.keyId)) findings.push(`duplicate-signer-key:${record.keyId}`);
117
- ids.add(record.signerId); keys.add(record.keyId);
118
- }
119
- for (const entry of registry.history) {
120
- if (!exactKeys(entry, ["kind", "signerId", "keyId", "replacedAt", "value", "authority"])
121
- || entry.kind !== "signer-rotation" || entry.signerId !== entry.value?.signerId
122
- || entry.keyId !== entry.value?.keyId || !validDate(entry.replacedAt)
123
- || entry.authority !== "context-only") findings.push(`invalid-signer-history:${entry?.signerId || "unknown"}`);
124
- try { validatePublicIdentity(entry.value); } catch { findings.push(`invalid-retired-signer:${entry?.signerId || "unknown"}`); }
125
- }
126
- return [...new Set(findings)];
127
- }
128
-
129
- export function trustFindings(trust) {
130
- const findings = [];
131
- if (!exactKeys(trust, ["schema", "root", "records", "history"])
132
- || trust.schema !== "agentspine.sharing-trust/v1" || typeof trust.root !== "string"
133
- || !Array.isArray(trust.records) || !Array.isArray(trust.history)) return ["invalid-sharing-trust"];
134
- const keys = new Set();
135
- for (const record of trust.records) {
136
- try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-trusted-signer:${record?.keyId || "unknown"}`); }
137
- if (!validTrustRecord(record)) findings.push(`invalid-trust-record:${record?.keyId || "unknown"}`);
138
- if (keys.has(record.keyId)) findings.push(`duplicate-trusted-key:${record.keyId}`);
139
- keys.add(record.keyId);
140
- }
141
- for (const entry of trust.history) {
142
- if (!exactKeys(entry, ["kind", "keyId", "changedAt", "value", "authority"])
143
- || entry.kind !== "trust-record" || entry.keyId !== entry.value?.keyId
144
- || !validDate(entry.changedAt) || entry.authority !== "context-only") {
145
- findings.push(`invalid-trust-history:${entry?.keyId || "unknown"}`);
146
- }
147
- if (!validTrustRecord(entry.value)) findings.push(`invalid-historical-trust:${entry?.keyId || "unknown"}`);
148
- try { validatePublicIdentity(entry.value?.publicIdentity); } catch { findings.push(`invalid-historical-trusted-signer:${entry?.keyId || "unknown"}`); }
149
- }
150
- return [...new Set(findings)];
151
- }
152
-
153
- function validTrustRecord(record) {
154
- if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "status", "addedAt", "revokedAt", "reason", "confirmation", "authority"])
155
- || record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
156
- || !new Set(["trusted", "revoked"]).has(record.status) || !validDate(record.addedAt)
157
- || record.confirmation !== "local-owner" || record.authority !== "context-only") return false;
158
- if (record.status === "trusted") return record.revokedAt === null && record.reason === null;
159
- return validDate(record.revokedAt) && typeof record.reason === "string" && Boolean(record.reason.trim());
160
- }
161
-
162
- async function authPaths(root, providedCatalog = null, { allowInside = false, create = true } = {}) {
163
- const catalog = providedCatalog || await buildCatalog(root);
164
- const globalRoot = resolve(stateRoot());
165
- const signerDirectory = join(globalRoot, "signers");
166
- if (!allowInside && !statePathIsScanExcluded(catalog, signerDirectory)) {
167
- throw new Error("signing identity state must remain outside the scanned project or in an excluded home-state root");
168
- }
169
- if (create) await mkdir(join(signerDirectory, "private"), { recursive: true, mode: 0o700 });
170
- const projectDirectory = create
171
- ? await projectStateDir(catalog.root)
172
- : join(globalRoot, "projects", projectId(catalog.root));
173
- return {
174
- catalog,
175
- signerDirectory,
176
- registryPath: join(signerDirectory, "registry.json"),
177
- trustPath: join(projectDirectory, "sharing-trust.json")
178
- };
179
- }
180
-
181
- async function readBoundedJson(path, fallback, normalize) {
182
- try {
183
- const metadata = await stat(path);
184
- if (metadata.size > MAX_AUTH_BYTES) throw new Error("authentication state exceeds the 2 MiB read limit");
185
- const value = JSON.parse(await readFile(path, "utf8"));
186
- return normalize(value);
187
- } catch (error) {
188
- if (error.code !== "ENOENT") throw error;
189
- return fallback();
190
- }
191
- }
192
-
193
- async function atomicWrite(path, value, mode = 0o600) {
194
- const content = `${JSON.stringify(value, null, 2)}\n`;
195
- if (Buffer.byteLength(content) > MAX_AUTH_BYTES) throw new Error("authentication state exceeds 2 MiB");
196
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
197
- await writeFile(temporary, content, { mode });
198
- await replaceFileWithRetry(temporary, path);
199
- }
200
-
201
- async function acquireLock(path) {
202
- const lockPath = `${path}.lock`;
203
- for (let attempt = 0; attempt < 80; attempt += 1) {
204
- try {
205
- return { handle: await open(lockPath, "wx", 0o600), lockPath };
206
- } catch (error) {
207
- if (!isFileLockContention(error)) throw error;
208
- try {
209
- const metadata = await stat(lockPath);
210
- if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
211
- } catch (lockError) {
212
- if (lockError.code !== "ENOENT") throw lockError;
213
- }
214
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
215
- }
216
- }
217
- throw new Error("authentication state is busy; retry shortly");
218
- }
219
-
220
- async function mutate(path, read, findings, operation) {
221
- const { handle, lockPath } = await acquireLock(path);
222
- try {
223
- const state = await read();
224
- const before = findings(state);
225
- if (before.length) throw new Error(`authentication state failed closed: ${before.join(", ")}`);
226
- const result = await operation(state);
227
- const after = findings(state);
228
- if (after.length) throw new Error(`authentication mutation failed closed: ${after.join(", ")}`);
229
- await atomicWrite(path, state);
230
- return result;
231
- } finally {
232
- await handle.close();
233
- await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
234
- }
235
- }
236
-
237
- function normalizeRegistry(value) {
238
- const findings = registryFindings(value);
239
- if (findings.length) throw new Error(`signer registry is invalid: ${findings.join(", ")}`);
240
- return value;
241
- }
242
-
243
- function normalizeTrust(value, root) {
244
- if (value?.root !== root) throw new Error("sharing trust state belongs to another project");
245
- const findings = trustFindings(value);
246
- if (findings.length) throw new Error(`sharing trust state is invalid: ${findings.join(", ")}`);
247
- return value;
248
- }
249
-
250
- function privatePath(directory, keyId) {
251
- return join(directory, "private", `${createHash("sha256").update(keyId).digest("hex")}.pem`);
252
- }
253
-
254
- export async function generateSigningIdentity({
255
- root = process.cwd(), signerId, rotate = false, publicOut = null,
256
- confirmation, now = new Date()
257
- }) {
258
- requireConfirmation(confirmation);
259
- if (!ID_RE.test(signerId || "")) throw new Error("signerId must be a stable, whitespace-free identifier");
260
- const publicTarget = publicOut ? resolve(publicOut) : null;
261
- if (publicTarget) {
262
- try {
263
- await lstat(publicTarget);
264
- throw new Error(`public identity output already exists: ${publicTarget}`);
265
- } catch (error) {
266
- if (error.code !== "ENOENT") throw error;
267
- }
268
- }
269
- const paths = await authPaths(root);
270
- const createdAt = isoDate(now, "now");
271
- const result = await mutate(
272
- paths.registryPath,
273
- () => readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry),
274
- registryFindings,
275
- async (registry) => {
276
- const existing = registry.signers.find((record) => record.signerId === signerId);
277
- if (existing && !rotate) throw new Error(`signer already exists: ${signerId}; use explicit rotation`);
278
- const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
279
- publicKeyEncoding: { format: "der", type: "spki" },
280
- privateKeyEncoding: { format: "pem", type: "pkcs8" }
281
- });
282
- const body = publicIdentityBody({ signerId, publicKey: publicKey.toString("base64"), createdAt });
283
- const publicIdentity = { ...body, digest: digest(body) };
284
- validatePublicIdentity(publicIdentity);
285
- const path = privatePath(paths.signerDirectory, publicIdentity.keyId);
286
- await writeFile(path, privateKey, { flag: "wx", mode: 0o600 });
287
- await chmod(path, 0o600);
288
- if (existing) {
289
- registry.history.push({
290
- kind: "signer-rotation", signerId, keyId: existing.keyId, replacedAt: createdAt,
291
- value: existing.publicIdentity, authority: "context-only"
292
- });
293
- }
294
- const record = { signerId, keyId: publicIdentity.keyId, publicIdentity, createdAt, authority: "context-only" };
295
- registry.signers = [...registry.signers.filter((item) => item.signerId !== signerId), record]
296
- .sort((a, b) => a.signerId.localeCompare(b.signerId));
297
- return { signerId, keyId: record.keyId, publicIdentity, retiredKeyId: existing?.keyId || null };
298
- }
299
- );
300
- if (result.retiredKeyId) await unlink(privatePath(paths.signerDirectory, result.retiredKeyId)).catch((error) => {
301
- if (error.code !== "ENOENT") throw error;
302
- });
303
- if (publicOut) {
304
- await mkdir(dirname(publicTarget), { recursive: true, mode: 0o700 });
305
- await writeFile(publicTarget, `${JSON.stringify(result.publicIdentity, null, 2)}\n`, { flag: "wx", mode: 0o644 });
306
- }
307
- return { ...result, publicOut: publicTarget, authority: "context-only" };
308
- }
309
-
310
- export async function listSigningIdentities({ root = process.cwd() } = {}) {
311
- const paths = await authPaths(root);
312
- const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
313
- return {
314
- schema: registry.schema,
315
- signers: registry.signers.map((record) => record.publicIdentity),
316
- retired: registry.history.map((entry) => entry.value),
317
- authority: "context-only"
318
- };
319
- }
320
-
321
- export async function inspectSignerRegistry(root = process.cwd(), providedCatalog = null) {
322
- const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
323
- try {
324
- const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
325
- const errors = [];
326
- for (const record of registry.signers) {
327
- const path = privatePath(paths.signerDirectory, record.keyId);
328
- try {
329
- const metadata = await lstat(path);
330
- if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
331
- errors.push(`invalid-private-key-file:${record.signerId}`);
332
- continue;
333
- }
334
- if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) errors.push(`unsafe-private-key-mode:${record.signerId}`);
335
- const privateKey = createPrivateKey(await readFile(path, "utf8"));
336
- const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).toString("base64");
337
- if (privateKey.asymmetricKeyType !== "ed25519" || publicKey !== record.publicIdentity.publicKey) {
338
- errors.push(`private-public-key-mismatch:${record.signerId}`);
339
- }
340
- } catch (error) {
341
- errors.push(`unreadable-private-key:${record.signerId}:${error.code || "invalid"}`);
342
- }
343
- }
344
- return { registry, registryPath: paths.registryPath, signerDirectory: paths.signerDirectory, errors, catalog: paths.catalog };
345
- } catch (error) {
346
- return {
347
- registry: emptyRegistry(), registryPath: paths.registryPath, signerDirectory: paths.signerDirectory,
348
- errors: [`unreadable-registry:${error.message}`], catalog: paths.catalog
349
- };
350
- }
351
- }
352
-
353
- async function activeIdentity(root, signerId) {
354
- const paths = await authPaths(root);
355
- const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
356
- const record = registry.signers.find((item) => item.signerId === signerId);
357
- if (!record) throw new Error(`unknown local signer: ${signerId}`);
358
- const path = privatePath(paths.signerDirectory, record.keyId);
359
- const metadata = await lstat(path);
360
- if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("signing private key must be a regular file");
361
- if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) throw new Error("signing private key permissions are too broad");
362
- const privateKey = createPrivateKey(await readFile(path, "utf8"));
363
- if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("signing private key is not Ed25519");
364
- return { publicIdentity: record.publicIdentity, privateKey };
365
- }
366
-
367
- export async function signEnvelope({ root = process.cwd(), signerId, kind, payload, now = new Date() }) {
368
- if (!new Set(["manifest", "event"]).has(kind)) throw new Error("signed envelope kind must be manifest or event");
369
- const identity = await activeIdentity(root, signerId);
370
- const body = {
371
- schema: "agentspine.signed-envelope/v1",
372
- kind,
373
- signer: identity.publicIdentity,
374
- payload,
375
- signedAt: isoDate(now, "now"),
376
- authority: "context-only"
377
- };
378
- const signature = cryptoSign(null, Buffer.from(canonical(body)), identity.privateKey).toString("base64");
379
- return { ...body, signature };
380
- }
381
-
382
- export function verifyEnvelope(envelope, expectedKind = null) {
383
- if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)
384
- || !exactKeys(envelope, ["schema", "kind", "signer", "payload", "signedAt", "authority", "signature"])
385
- || envelope.schema !== "agentspine.signed-envelope/v1"
386
- || !new Set(["manifest", "event"]).has(envelope.kind)
387
- || (expectedKind !== null && envelope.kind !== expectedKind)
388
- || !validDate(envelope.signedAt) || envelope.authority !== "context-only"
389
- || !SIGNATURE_RE.test(envelope.signature || "")) {
390
- throw new Error("signed envelope structure is invalid");
391
- }
392
- validatePublicIdentity(envelope.signer);
393
- const body = { ...envelope };
394
- delete body.signature;
395
- const key = createPublicKey({ key: Buffer.from(envelope.signer.publicKey, "base64"), format: "der", type: "spki" });
396
- if (!cryptoVerify(null, Buffer.from(canonical(body)), key, Buffer.from(envelope.signature, "base64"))) {
397
- throw new Error("signed envelope signature is invalid");
398
- }
399
- return {
400
- payload: envelope.payload,
401
- authentication: {
402
- mode: "signed",
403
- signerId: envelope.signer.signerId,
404
- keyId: envelope.signer.keyId,
405
- signedAt: envelope.signedAt,
406
- verifiedAt: null,
407
- signature: envelope.signature,
408
- authority: "context-only"
409
- },
410
- publicIdentity: envelope.signer
411
- };
412
- }
413
-
414
- export async function loadTrust(root = process.cwd(), providedCatalog = null) {
415
- const paths = await authPaths(root, providedCatalog);
416
- const trust = await readBoundedJson(
417
- paths.trustPath,
418
- () => emptyTrust(paths.catalog.root),
419
- (value) => normalizeTrust(value, paths.catalog.root)
420
- );
421
- return { trust, trustPath: paths.trustPath, catalog: paths.catalog };
422
- }
423
-
424
- export async function inspectTrust(root = process.cwd(), providedCatalog = null) {
425
- const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
426
- try {
427
- const trust = await readBoundedJson(
428
- paths.trustPath, () => emptyTrust(paths.catalog.root),
429
- (value) => normalizeTrust(value, paths.catalog.root)
430
- );
431
- return { trust, trustPath: paths.trustPath, catalog: paths.catalog, error: null };
432
- } catch (error) {
433
- return { trust: emptyTrust(paths.catalog.root), trustPath: paths.trustPath, catalog: paths.catalog, error: error.message };
434
- }
435
- }
436
-
437
- async function trustMutation(root, operation) {
438
- const paths = await authPaths(root);
439
- return mutate(
440
- paths.trustPath,
441
- () => readBoundedJson(paths.trustPath, () => emptyTrust(paths.catalog.root), (value) => normalizeTrust(value, paths.catalog.root)),
442
- trustFindings,
443
- (trust) => operation(trust, paths)
444
- );
445
- }
446
-
447
- export async function trustSigner({ root = process.cwd(), publicIdentityPath, confirmation, now = new Date() }) {
448
- requireConfirmation(confirmation);
449
- if (!publicIdentityPath || typeof publicIdentityPath !== "string") throw new Error("public identity path is required");
450
- const target = resolve(publicIdentityPath);
451
- const metadata = await lstat(target);
452
- if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
453
- throw new Error("public identity must be a regular JSON file smaller than 16 KiB");
454
- }
455
- const publicIdentity = validatePublicIdentity(JSON.parse(await readFile(target, "utf8")));
456
- const addedAt = isoDate(now, "now");
457
- return trustMutation(root, (trust, paths) => {
458
- const existing = trust.records.find((record) => record.keyId === publicIdentity.keyId);
459
- if (existing?.status === "trusted") return { created: false, record: existing, trustPath: paths.trustPath };
460
- if (existing?.status === "revoked") throw new Error("a revoked key cannot be silently trusted again; import a rotated key");
461
- const record = {
462
- signerId: publicIdentity.signerId, keyId: publicIdentity.keyId, publicIdentity,
463
- status: "trusted", addedAt, revokedAt: null, reason: null,
464
- confirmation: "local-owner", authority: "context-only"
465
- };
466
- trust.records.push(record);
467
- trust.records.sort((a, b) => a.signerId.localeCompare(b.signerId) || a.keyId.localeCompare(b.keyId));
468
- return { created: true, record, trustPath: paths.trustPath };
469
- });
470
- }
471
-
472
- export async function revokeTrustedSigner({ root = process.cwd(), keyId, reason, confirmation, now = new Date() }) {
473
- requireConfirmation(confirmation);
474
- if (!ID_RE.test(keyId || "")) throw new Error("keyId is required");
475
- if (!reason || typeof reason !== "string" || !reason.trim()) throw new Error("revocation reason is required");
476
- if (SECRET_RE.test(reason)) throw new Error("revocation reason appears to contain a secret");
477
- const revokedAt = isoDate(now, "now");
478
- return trustMutation(root, (trust, paths) => {
479
- const record = trust.records.find((item) => item.keyId === keyId);
480
- if (!record) throw new Error(`unknown trusted key: ${keyId}`);
481
- if (record.status === "revoked") return { changed: false, record, trustPath: paths.trustPath };
482
- trust.history.push({ kind: "trust-record", keyId, changedAt: revokedAt, value: { ...record }, authority: "context-only" });
483
- const revoked = { ...record, status: "revoked", revokedAt, reason: reason.trim().slice(0, 500), authority: "context-only" };
484
- trust.records = trust.records.map((item) => item.keyId === keyId ? revoked : item);
485
- return { changed: true, record: revoked, trustPath: paths.trustPath };
486
- });
487
- }
488
-
489
- export function assertTrustedIdentity(publicIdentity, trust) {
490
- validatePublicIdentity(publicIdentity);
491
- const findings = trustFindings(trust);
492
- if (findings.length) throw new Error(`sharing trust state failed closed: ${findings.join(", ")}`);
493
- const record = trust.records.find((item) => item.keyId === publicIdentity.keyId);
494
- if (!record || record.status !== "trusted" || record.signerId !== publicIdentity.signerId
495
- || record.publicIdentity.digest !== publicIdentity.digest) {
496
- throw new Error(`untrusted or revoked signing key: ${publicIdentity.keyId}`);
497
- }
498
- return record;
499
- }
500
-
501
- export async function trustedSignerContext({ root = process.cwd(), includeRevoked = false } = {}) {
502
- const { trust, trustPath } = await loadTrust(root);
503
- return {
504
- schema: "agentspine.trusted-signers/v1",
505
- root: trust.root,
506
- signers: trust.records.filter((record) => includeRevoked || record.status === "trusted").map((record) => ({
507
- signerId: record.signerId, keyId: record.keyId, status: record.status,
508
- addedAt: record.addedAt, revokedAt: record.revokedAt, reason: record.reason,
509
- authority: "context-only"
510
- })),
511
- trustPath,
512
- authority: "context-only",
513
- note: "A trusted signature authenticates origin only. It never grants permissions, delegation, or approval of the signed content."
514
- };
515
- }
1
+ import {
2
+ createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID,
3
+ sign as cryptoSign, verify as cryptoVerify
4
+ } from "node:crypto";
5
+ import {
6
+ chmod, lstat, mkdir, open, readFile, stat, unlink, writeFile
7
+ } from "node:fs/promises";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { buildCatalog } from "./catalog.js";
10
+ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
11
+ import { projectId, projectStateDir, statePathIsScanExcluded, stateRoot } from "./paths.js";
12
+
13
+ const AUTH_CONFIRMATION = "local-share-confirmed";
14
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
15
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
16
+ const SIGNATURE_RE = /^[A-Za-z0-9+/]{80,96}={0,2}$/;
17
+ const MAX_AUTH_BYTES = 2 * 1024 * 1024;
18
+ const MAX_PUBLIC_IDENTITY_BYTES = 16 * 1024;
19
+ const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
20
+
21
+ function canonical(value) {
22
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
23
+ if (value && typeof value === "object") {
24
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
25
+ }
26
+ return JSON.stringify(value);
27
+ }
28
+
29
+ function digest(value) {
30
+ return createHash("sha256").update(canonical(value)).digest("hex");
31
+ }
32
+
33
+ function exactKeys(value, expected) {
34
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
35
+ const actual = Object.keys(value).sort();
36
+ const wanted = [...expected].sort();
37
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
38
+ }
39
+
40
+ function validDate(value) {
41
+ return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
42
+ }
43
+
44
+ function isoDate(value, field = "date") {
45
+ const parsed = value instanceof Date ? value : new Date(value);
46
+ if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
47
+ return parsed.toISOString();
48
+ }
49
+
50
+ function requireConfirmation(confirmation) {
51
+ if (confirmation !== AUTH_CONFIRMATION) throw new Error("signer and trust changes require explicit local owner confirmation");
52
+ }
53
+
54
+ function emptyRegistry() {
55
+ return { schema: "agentspine.signers/v1", signers: [], history: [] };
56
+ }
57
+
58
+ function emptyTrust(root) {
59
+ return { schema: "agentspine.sharing-trust/v1", root, records: [], history: [] };
60
+ }
61
+
62
+ function publicIdentityBody({ signerId, publicKey, createdAt }) {
63
+ const keyBytes = Buffer.from(publicKey, "base64");
64
+ return {
65
+ schema: "agentspine.signer/v1",
66
+ signerId,
67
+ keyId: `ed25519:${createHash("sha256").update(keyBytes).digest("hex")}`,
68
+ algorithm: "Ed25519",
69
+ publicKey,
70
+ createdAt,
71
+ authority: "context-only"
72
+ };
73
+ }
74
+
75
+ export function validatePublicIdentity(identity) {
76
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)
77
+ || !exactKeys(identity, ["schema", "signerId", "keyId", "algorithm", "publicKey", "createdAt", "authority", "digest"])
78
+ || identity.schema !== "agentspine.signer/v1" || !ID_RE.test(identity.signerId || "")
79
+ || !ID_RE.test(identity.keyId || "") || identity.algorithm !== "Ed25519"
80
+ || typeof identity.publicKey !== "string" || identity.publicKey.length > 1024
81
+ || !validDate(identity.createdAt) || identity.authority !== "context-only"
82
+ || !DIGEST_RE.test(identity.digest || "")) {
83
+ throw new Error("public signing identity is invalid");
84
+ }
85
+ let key;
86
+ try {
87
+ key = createPublicKey({ key: Buffer.from(identity.publicKey, "base64"), format: "der", type: "spki" });
88
+ } catch {
89
+ throw new Error("public signing identity contains an invalid Ed25519 key");
90
+ }
91
+ if (key.asymmetricKeyType !== "ed25519") throw new Error("public signing identity must use Ed25519");
92
+ const body = { ...identity };
93
+ delete body.digest;
94
+ const expected = publicIdentityBody({ signerId: identity.signerId, publicKey: identity.publicKey, createdAt: identity.createdAt });
95
+ if (identity.keyId !== expected.keyId || identity.digest !== digest(body)) {
96
+ throw new Error("public signing identity failed its fingerprint or integrity check");
97
+ }
98
+ return identity;
99
+ }
100
+
101
+ function registryFindings(registry) {
102
+ const findings = [];
103
+ if (!exactKeys(registry, ["schema", "signers", "history"])
104
+ || registry.schema !== "agentspine.signers/v1" || !Array.isArray(registry.signers)
105
+ || !Array.isArray(registry.history)) return ["invalid-signer-registry"];
106
+ const ids = new Set();
107
+ const keys = new Set();
108
+ for (const record of registry.signers) {
109
+ try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-signer:${record?.signerId || "unknown"}`); }
110
+ if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "createdAt", "authority"])
111
+ || record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
112
+ || record.createdAt !== record.publicIdentity?.createdAt || record.authority !== "context-only") {
113
+ findings.push(`invalid-signer-record:${record?.signerId || "unknown"}`);
114
+ }
115
+ if (ids.has(record.signerId)) findings.push(`duplicate-signer:${record.signerId}`);
116
+ if (keys.has(record.keyId)) findings.push(`duplicate-signer-key:${record.keyId}`);
117
+ ids.add(record.signerId); keys.add(record.keyId);
118
+ }
119
+ for (const entry of registry.history) {
120
+ if (!exactKeys(entry, ["kind", "signerId", "keyId", "replacedAt", "value", "authority"])
121
+ || entry.kind !== "signer-rotation" || entry.signerId !== entry.value?.signerId
122
+ || entry.keyId !== entry.value?.keyId || !validDate(entry.replacedAt)
123
+ || entry.authority !== "context-only") findings.push(`invalid-signer-history:${entry?.signerId || "unknown"}`);
124
+ try { validatePublicIdentity(entry.value); } catch { findings.push(`invalid-retired-signer:${entry?.signerId || "unknown"}`); }
125
+ }
126
+ return [...new Set(findings)];
127
+ }
128
+
129
+ export function trustFindings(trust) {
130
+ const findings = [];
131
+ if (!exactKeys(trust, ["schema", "root", "records", "history"])
132
+ || trust.schema !== "agentspine.sharing-trust/v1" || typeof trust.root !== "string"
133
+ || !Array.isArray(trust.records) || !Array.isArray(trust.history)) return ["invalid-sharing-trust"];
134
+ const keys = new Set();
135
+ for (const record of trust.records) {
136
+ try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-trusted-signer:${record?.keyId || "unknown"}`); }
137
+ if (!validTrustRecord(record)) findings.push(`invalid-trust-record:${record?.keyId || "unknown"}`);
138
+ if (keys.has(record.keyId)) findings.push(`duplicate-trusted-key:${record.keyId}`);
139
+ keys.add(record.keyId);
140
+ }
141
+ for (const entry of trust.history) {
142
+ if (!exactKeys(entry, ["kind", "keyId", "changedAt", "value", "authority"])
143
+ || entry.kind !== "trust-record" || entry.keyId !== entry.value?.keyId
144
+ || !validDate(entry.changedAt) || entry.authority !== "context-only") {
145
+ findings.push(`invalid-trust-history:${entry?.keyId || "unknown"}`);
146
+ }
147
+ if (!validTrustRecord(entry.value)) findings.push(`invalid-historical-trust:${entry?.keyId || "unknown"}`);
148
+ try { validatePublicIdentity(entry.value?.publicIdentity); } catch { findings.push(`invalid-historical-trusted-signer:${entry?.keyId || "unknown"}`); }
149
+ }
150
+ return [...new Set(findings)];
151
+ }
152
+
153
+ function validTrustRecord(record) {
154
+ if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "status", "addedAt", "revokedAt", "reason", "confirmation", "authority"])
155
+ || record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
156
+ || !new Set(["trusted", "revoked"]).has(record.status) || !validDate(record.addedAt)
157
+ || record.confirmation !== "local-owner" || record.authority !== "context-only") return false;
158
+ if (record.status === "trusted") return record.revokedAt === null && record.reason === null;
159
+ return validDate(record.revokedAt) && typeof record.reason === "string" && Boolean(record.reason.trim());
160
+ }
161
+
162
+ async function authPaths(root, providedCatalog = null, { allowInside = false, create = true } = {}) {
163
+ const catalog = providedCatalog || await buildCatalog(root);
164
+ const globalRoot = resolve(stateRoot());
165
+ const signerDirectory = join(globalRoot, "signers");
166
+ if (!allowInside && !statePathIsScanExcluded(catalog, signerDirectory)) {
167
+ throw new Error("signing identity state must remain outside the scanned project or in an excluded home-state root");
168
+ }
169
+ if (create) await mkdir(join(signerDirectory, "private"), { recursive: true, mode: 0o700 });
170
+ const projectDirectory = create
171
+ ? await projectStateDir(catalog.root)
172
+ : join(globalRoot, "projects", projectId(catalog.root));
173
+ return {
174
+ catalog,
175
+ signerDirectory,
176
+ registryPath: join(signerDirectory, "registry.json"),
177
+ trustPath: join(projectDirectory, "sharing-trust.json")
178
+ };
179
+ }
180
+
181
+ async function readBoundedJson(path, fallback, normalize) {
182
+ try {
183
+ const metadata = await stat(path);
184
+ if (metadata.size > MAX_AUTH_BYTES) throw new Error("authentication state exceeds the 2 MiB read limit");
185
+ const value = JSON.parse(await readFile(path, "utf8"));
186
+ return normalize(value);
187
+ } catch (error) {
188
+ if (error.code !== "ENOENT") throw error;
189
+ return fallback();
190
+ }
191
+ }
192
+
193
+ async function atomicWrite(path, value, mode = 0o600) {
194
+ const content = `${JSON.stringify(value, null, 2)}\n`;
195
+ if (Buffer.byteLength(content) > MAX_AUTH_BYTES) throw new Error("authentication state exceeds 2 MiB");
196
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
197
+ await writeFile(temporary, content, { mode });
198
+ await replaceFileWithRetry(temporary, path);
199
+ }
200
+
201
+ async function acquireLock(path) {
202
+ const lockPath = `${path}.lock`;
203
+ for (let attempt = 0; attempt < 80; attempt += 1) {
204
+ try {
205
+ return { handle: await open(lockPath, "wx", 0o600), lockPath };
206
+ } catch (error) {
207
+ if (!isFileLockContention(error)) throw error;
208
+ try {
209
+ const metadata = await stat(lockPath);
210
+ if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
211
+ } catch (lockError) {
212
+ if (lockError.code !== "ENOENT") throw lockError;
213
+ }
214
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
215
+ }
216
+ }
217
+ throw new Error("authentication state is busy; retry shortly");
218
+ }
219
+
220
+ async function mutate(path, read, findings, operation) {
221
+ const { handle, lockPath } = await acquireLock(path);
222
+ try {
223
+ const state = await read();
224
+ const before = findings(state);
225
+ if (before.length) throw new Error(`authentication state failed closed: ${before.join(", ")}`);
226
+ const result = await operation(state);
227
+ const after = findings(state);
228
+ if (after.length) throw new Error(`authentication mutation failed closed: ${after.join(", ")}`);
229
+ await atomicWrite(path, state);
230
+ return result;
231
+ } finally {
232
+ await handle.close();
233
+ await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
234
+ }
235
+ }
236
+
237
+ function normalizeRegistry(value) {
238
+ const findings = registryFindings(value);
239
+ if (findings.length) throw new Error(`signer registry is invalid: ${findings.join(", ")}`);
240
+ return value;
241
+ }
242
+
243
+ function normalizeTrust(value, root) {
244
+ if (value?.root !== root) throw new Error("sharing trust state belongs to another project");
245
+ const findings = trustFindings(value);
246
+ if (findings.length) throw new Error(`sharing trust state is invalid: ${findings.join(", ")}`);
247
+ return value;
248
+ }
249
+
250
+ function privatePath(directory, keyId) {
251
+ return join(directory, "private", `${createHash("sha256").update(keyId).digest("hex")}.pem`);
252
+ }
253
+
254
+ export async function generateSigningIdentity({
255
+ root = process.cwd(), signerId, rotate = false, publicOut = null,
256
+ confirmation, now = new Date()
257
+ }) {
258
+ requireConfirmation(confirmation);
259
+ if (!ID_RE.test(signerId || "")) throw new Error("signerId must be a stable, whitespace-free identifier");
260
+ const publicTarget = publicOut ? resolve(publicOut) : null;
261
+ if (publicTarget) {
262
+ try {
263
+ await lstat(publicTarget);
264
+ throw new Error(`public identity output already exists: ${publicTarget}`);
265
+ } catch (error) {
266
+ if (error.code !== "ENOENT") throw error;
267
+ }
268
+ }
269
+ const paths = await authPaths(root);
270
+ const createdAt = isoDate(now, "now");
271
+ const result = await mutate(
272
+ paths.registryPath,
273
+ () => readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry),
274
+ registryFindings,
275
+ async (registry) => {
276
+ const existing = registry.signers.find((record) => record.signerId === signerId);
277
+ if (existing && !rotate) throw new Error(`signer already exists: ${signerId}; use explicit rotation`);
278
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
279
+ publicKeyEncoding: { format: "der", type: "spki" },
280
+ privateKeyEncoding: { format: "pem", type: "pkcs8" }
281
+ });
282
+ const body = publicIdentityBody({ signerId, publicKey: publicKey.toString("base64"), createdAt });
283
+ const publicIdentity = { ...body, digest: digest(body) };
284
+ validatePublicIdentity(publicIdentity);
285
+ const path = privatePath(paths.signerDirectory, publicIdentity.keyId);
286
+ await writeFile(path, privateKey, { flag: "wx", mode: 0o600 });
287
+ await chmod(path, 0o600);
288
+ if (existing) {
289
+ registry.history.push({
290
+ kind: "signer-rotation", signerId, keyId: existing.keyId, replacedAt: createdAt,
291
+ value: existing.publicIdentity, authority: "context-only"
292
+ });
293
+ }
294
+ const record = { signerId, keyId: publicIdentity.keyId, publicIdentity, createdAt, authority: "context-only" };
295
+ registry.signers = [...registry.signers.filter((item) => item.signerId !== signerId), record]
296
+ .sort((a, b) => a.signerId.localeCompare(b.signerId));
297
+ return { signerId, keyId: record.keyId, publicIdentity, retiredKeyId: existing?.keyId || null };
298
+ }
299
+ );
300
+ if (result.retiredKeyId) await unlink(privatePath(paths.signerDirectory, result.retiredKeyId)).catch((error) => {
301
+ if (error.code !== "ENOENT") throw error;
302
+ });
303
+ if (publicOut) {
304
+ await mkdir(dirname(publicTarget), { recursive: true, mode: 0o700 });
305
+ await writeFile(publicTarget, `${JSON.stringify(result.publicIdentity, null, 2)}\n`, { flag: "wx", mode: 0o644 });
306
+ }
307
+ return { ...result, publicOut: publicTarget, authority: "context-only" };
308
+ }
309
+
310
+ export async function listSigningIdentities({ root = process.cwd() } = {}) {
311
+ const paths = await authPaths(root);
312
+ const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
313
+ return {
314
+ schema: registry.schema,
315
+ signers: registry.signers.map((record) => record.publicIdentity),
316
+ retired: registry.history.map((entry) => entry.value),
317
+ authority: "context-only"
318
+ };
319
+ }
320
+
321
+ export async function inspectSignerRegistry(root = process.cwd(), providedCatalog = null) {
322
+ const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
323
+ try {
324
+ const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
325
+ const errors = [];
326
+ for (const record of registry.signers) {
327
+ const path = privatePath(paths.signerDirectory, record.keyId);
328
+ try {
329
+ const metadata = await lstat(path);
330
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
331
+ errors.push(`invalid-private-key-file:${record.signerId}`);
332
+ continue;
333
+ }
334
+ if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) errors.push(`unsafe-private-key-mode:${record.signerId}`);
335
+ const privateKey = createPrivateKey(await readFile(path, "utf8"));
336
+ const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).toString("base64");
337
+ if (privateKey.asymmetricKeyType !== "ed25519" || publicKey !== record.publicIdentity.publicKey) {
338
+ errors.push(`private-public-key-mismatch:${record.signerId}`);
339
+ }
340
+ } catch (error) {
341
+ errors.push(`unreadable-private-key:${record.signerId}:${error.code || "invalid"}`);
342
+ }
343
+ }
344
+ return { registry, registryPath: paths.registryPath, signerDirectory: paths.signerDirectory, errors, catalog: paths.catalog };
345
+ } catch (error) {
346
+ return {
347
+ registry: emptyRegistry(), registryPath: paths.registryPath, signerDirectory: paths.signerDirectory,
348
+ errors: [`unreadable-registry:${error.message}`], catalog: paths.catalog
349
+ };
350
+ }
351
+ }
352
+
353
+ async function activeIdentity(root, signerId) {
354
+ const paths = await authPaths(root);
355
+ const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
356
+ const record = registry.signers.find((item) => item.signerId === signerId);
357
+ if (!record) throw new Error(`unknown local signer: ${signerId}`);
358
+ const path = privatePath(paths.signerDirectory, record.keyId);
359
+ const metadata = await lstat(path);
360
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("signing private key must be a regular file");
361
+ if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) throw new Error("signing private key permissions are too broad");
362
+ const privateKey = createPrivateKey(await readFile(path, "utf8"));
363
+ if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("signing private key is not Ed25519");
364
+ return { publicIdentity: record.publicIdentity, privateKey };
365
+ }
366
+
367
+ export async function signEnvelope({ root = process.cwd(), signerId, kind, payload, now = new Date() }) {
368
+ if (!new Set(["manifest", "event"]).has(kind)) throw new Error("signed envelope kind must be manifest or event");
369
+ const identity = await activeIdentity(root, signerId);
370
+ const body = {
371
+ schema: "agentspine.signed-envelope/v1",
372
+ kind,
373
+ signer: identity.publicIdentity,
374
+ payload,
375
+ signedAt: isoDate(now, "now"),
376
+ authority: "context-only"
377
+ };
378
+ const signature = cryptoSign(null, Buffer.from(canonical(body)), identity.privateKey).toString("base64");
379
+ return { ...body, signature };
380
+ }
381
+
382
+ export function verifyEnvelope(envelope, expectedKind = null) {
383
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)
384
+ || !exactKeys(envelope, ["schema", "kind", "signer", "payload", "signedAt", "authority", "signature"])
385
+ || envelope.schema !== "agentspine.signed-envelope/v1"
386
+ || !new Set(["manifest", "event"]).has(envelope.kind)
387
+ || (expectedKind !== null && envelope.kind !== expectedKind)
388
+ || !validDate(envelope.signedAt) || envelope.authority !== "context-only"
389
+ || !SIGNATURE_RE.test(envelope.signature || "")) {
390
+ throw new Error("signed envelope structure is invalid");
391
+ }
392
+ validatePublicIdentity(envelope.signer);
393
+ const body = { ...envelope };
394
+ delete body.signature;
395
+ const key = createPublicKey({ key: Buffer.from(envelope.signer.publicKey, "base64"), format: "der", type: "spki" });
396
+ if (!cryptoVerify(null, Buffer.from(canonical(body)), key, Buffer.from(envelope.signature, "base64"))) {
397
+ throw new Error("signed envelope signature is invalid");
398
+ }
399
+ return {
400
+ payload: envelope.payload,
401
+ authentication: {
402
+ mode: "signed",
403
+ signerId: envelope.signer.signerId,
404
+ keyId: envelope.signer.keyId,
405
+ signedAt: envelope.signedAt,
406
+ verifiedAt: null,
407
+ signature: envelope.signature,
408
+ authority: "context-only"
409
+ },
410
+ publicIdentity: envelope.signer
411
+ };
412
+ }
413
+
414
+ export async function loadTrust(root = process.cwd(), providedCatalog = null) {
415
+ const paths = await authPaths(root, providedCatalog);
416
+ const trust = await readBoundedJson(
417
+ paths.trustPath,
418
+ () => emptyTrust(paths.catalog.root),
419
+ (value) => normalizeTrust(value, paths.catalog.root)
420
+ );
421
+ return { trust, trustPath: paths.trustPath, catalog: paths.catalog };
422
+ }
423
+
424
+ export async function inspectTrust(root = process.cwd(), providedCatalog = null) {
425
+ const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
426
+ try {
427
+ const trust = await readBoundedJson(
428
+ paths.trustPath, () => emptyTrust(paths.catalog.root),
429
+ (value) => normalizeTrust(value, paths.catalog.root)
430
+ );
431
+ return { trust, trustPath: paths.trustPath, catalog: paths.catalog, error: null };
432
+ } catch (error) {
433
+ return { trust: emptyTrust(paths.catalog.root), trustPath: paths.trustPath, catalog: paths.catalog, error: error.message };
434
+ }
435
+ }
436
+
437
+ async function trustMutation(root, operation) {
438
+ const paths = await authPaths(root);
439
+ return mutate(
440
+ paths.trustPath,
441
+ () => readBoundedJson(paths.trustPath, () => emptyTrust(paths.catalog.root), (value) => normalizeTrust(value, paths.catalog.root)),
442
+ trustFindings,
443
+ (trust) => operation(trust, paths)
444
+ );
445
+ }
446
+
447
+ export async function trustSigner({ root = process.cwd(), publicIdentityPath, confirmation, now = new Date() }) {
448
+ requireConfirmation(confirmation);
449
+ if (!publicIdentityPath || typeof publicIdentityPath !== "string") throw new Error("public identity path is required");
450
+ const target = resolve(publicIdentityPath);
451
+ const metadata = await lstat(target);
452
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
453
+ throw new Error("public identity must be a regular JSON file smaller than 16 KiB");
454
+ }
455
+ const publicIdentity = validatePublicIdentity(JSON.parse(await readFile(target, "utf8")));
456
+ const addedAt = isoDate(now, "now");
457
+ return trustMutation(root, (trust, paths) => {
458
+ const existing = trust.records.find((record) => record.keyId === publicIdentity.keyId);
459
+ if (existing?.status === "trusted") return { created: false, record: existing, trustPath: paths.trustPath };
460
+ if (existing?.status === "revoked") throw new Error("a revoked key cannot be silently trusted again; import a rotated key");
461
+ const record = {
462
+ signerId: publicIdentity.signerId, keyId: publicIdentity.keyId, publicIdentity,
463
+ status: "trusted", addedAt, revokedAt: null, reason: null,
464
+ confirmation: "local-owner", authority: "context-only"
465
+ };
466
+ trust.records.push(record);
467
+ trust.records.sort((a, b) => a.signerId.localeCompare(b.signerId) || a.keyId.localeCompare(b.keyId));
468
+ return { created: true, record, trustPath: paths.trustPath };
469
+ });
470
+ }
471
+
472
+ export async function revokeTrustedSigner({ root = process.cwd(), keyId, reason, confirmation, now = new Date() }) {
473
+ requireConfirmation(confirmation);
474
+ if (!ID_RE.test(keyId || "")) throw new Error("keyId is required");
475
+ if (!reason || typeof reason !== "string" || !reason.trim()) throw new Error("revocation reason is required");
476
+ if (SECRET_RE.test(reason)) throw new Error("revocation reason appears to contain a secret");
477
+ const revokedAt = isoDate(now, "now");
478
+ return trustMutation(root, (trust, paths) => {
479
+ const record = trust.records.find((item) => item.keyId === keyId);
480
+ if (!record) throw new Error(`unknown trusted key: ${keyId}`);
481
+ if (record.status === "revoked") return { changed: false, record, trustPath: paths.trustPath };
482
+ trust.history.push({ kind: "trust-record", keyId, changedAt: revokedAt, value: { ...record }, authority: "context-only" });
483
+ const revoked = { ...record, status: "revoked", revokedAt, reason: reason.trim().slice(0, 500), authority: "context-only" };
484
+ trust.records = trust.records.map((item) => item.keyId === keyId ? revoked : item);
485
+ return { changed: true, record: revoked, trustPath: paths.trustPath };
486
+ });
487
+ }
488
+
489
+ export function assertTrustedIdentity(publicIdentity, trust) {
490
+ validatePublicIdentity(publicIdentity);
491
+ const findings = trustFindings(trust);
492
+ if (findings.length) throw new Error(`sharing trust state failed closed: ${findings.join(", ")}`);
493
+ const record = trust.records.find((item) => item.keyId === publicIdentity.keyId);
494
+ if (!record || record.status !== "trusted" || record.signerId !== publicIdentity.signerId
495
+ || record.publicIdentity.digest !== publicIdentity.digest) {
496
+ throw new Error(`untrusted or revoked signing key: ${publicIdentity.keyId}`);
497
+ }
498
+ return record;
499
+ }
500
+
501
+ export async function trustedSignerContext({ root = process.cwd(), includeRevoked = false } = {}) {
502
+ const { trust, trustPath } = await loadTrust(root);
503
+ return {
504
+ schema: "agentspine.trusted-signers/v1",
505
+ root: trust.root,
506
+ signers: trust.records.filter((record) => includeRevoked || record.status === "trusted").map((record) => ({
507
+ signerId: record.signerId, keyId: record.keyId, status: record.status,
508
+ addedAt: record.addedAt, revokedAt: record.revokedAt, reason: record.reason,
509
+ authority: "context-only"
510
+ })),
511
+ trustPath,
512
+ authority: "context-only",
513
+ note: "A trusted signature authenticates origin only. It never grants permissions, delegation, or approval of the signed content."
514
+ };
515
+ }