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.
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,969 +1,969 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, writeFile
|
|
4
|
-
} from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import { buildCatalog } from "./catalog.js";
|
|
7
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
8
|
-
import { loadGraph } from "./graph.js";
|
|
9
|
-
import { learningFindings, loadLearning } from "./learning.js";
|
|
10
|
-
import { isInside, projectStateDir } from "./paths.js";
|
|
11
|
-
import {
|
|
12
|
-
assertTrustedIdentity, loadTrust, signEnvelope, trustFindings, verifyEnvelope
|
|
13
|
-
} from "./authentication.js";
|
|
14
|
-
|
|
15
|
-
const KINDS = new Set(["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"]);
|
|
16
|
-
const PRIVACY = new Set(["shared", "group"]);
|
|
17
|
-
const STATUSES = new Set(["pending", "accepted", "rejected", "superseded", "rolled-back"]);
|
|
18
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
19
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
20
|
-
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
21
|
-
const MAX_EVENT_BYTES = 64 * 1024;
|
|
22
|
-
const MAX_ADAPTER_EVENTS = 2000;
|
|
23
|
-
const MAX_ADAPTER_BYTES = 20 * 1024 * 1024;
|
|
24
|
-
const CONFIRMATION = "local-share-confirmed";
|
|
25
|
-
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,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/i;
|
|
26
|
-
const AUTHORITY_RE = /\b(?:user|agent|person|they|he|she|i|ich|wir|nutzer|benutzer).{0,60}\b(?:may|can|is allowed|is authorized|has|have|darf|berechtigt|hat|haben).{0,50}\b(?:admin(?:istrator)?|permissions?|rights?|authorization|production access|deploy|billing|spending|policy exception|bypass|zugang|rechte|berechtigung|produktion|abrechnung|ausnahme|umgehen)\b/i;
|
|
27
|
-
|
|
28
|
-
function canonical(value) {
|
|
29
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
30
|
-
if (value && typeof value === "object") {
|
|
31
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
32
|
-
}
|
|
33
|
-
return JSON.stringify(value);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function digest(value) {
|
|
37
|
-
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function withoutDigest(value) {
|
|
41
|
-
const { digest: _digest, ...rest } = value;
|
|
42
|
-
return rest;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function exactKeys(value, expected) {
|
|
46
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
47
|
-
const actual = Object.keys(value).sort();
|
|
48
|
-
const wanted = [...expected].sort();
|
|
49
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function safeText(value, field, maximum) {
|
|
53
|
-
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
54
|
-
const result = value.trim().slice(0, maximum);
|
|
55
|
-
if (!result) throw new Error(`${field} is required`);
|
|
56
|
-
if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter shared memory`);
|
|
57
|
-
return result;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function safeClaim(value) {
|
|
61
|
-
const result = safeText(value, "claim", 1000);
|
|
62
|
-
if (AUTHORITY_RE.test(result)) throw new Error("authority and access claims cannot enter shared memory");
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function date(value, field = "date") {
|
|
67
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
68
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
69
|
-
return parsed.toISOString();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function validDateValue(value) {
|
|
73
|
-
return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function integer(value, field, minimum, maximum) {
|
|
77
|
-
const result = Number(value);
|
|
78
|
-
if (!Number.isInteger(result) || result < minimum || result > maximum) throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`);
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function emptySharing(root) {
|
|
83
|
-
return {
|
|
84
|
-
schema: "agentspine.sharing/v1",
|
|
85
|
-
root,
|
|
86
|
-
instanceId: `instance:${randomUUID()}`,
|
|
87
|
-
config: { maxContextItems: 12 },
|
|
88
|
-
records: [],
|
|
89
|
-
history: []
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function normalizeSharing(value, root) {
|
|
94
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
95
|
-
|| value.schema !== "agentspine.sharing/v1" || value.root !== root
|
|
96
|
-
|| !ID_RE.test(value.instanceId || "") || !value.config || typeof value.config !== "object"
|
|
97
|
-
|| !Array.isArray(value.records) || !value.records.every((item) => item && typeof item === "object")
|
|
98
|
-
|| !Array.isArray(value.history) || !value.history.every((item) => item && typeof item === "object")) {
|
|
99
|
-
throw new Error("sharing state structure is invalid; run the audit before using shared memory");
|
|
100
|
-
}
|
|
101
|
-
return value;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function validConfig(config) {
|
|
105
|
-
return Number.isInteger(config?.maxContextItems) && config.maxContextItems >= 1 && config.maxContextItems <= 50;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function readJson(path, root) {
|
|
109
|
-
try {
|
|
110
|
-
const metadata = await stat(path);
|
|
111
|
-
if (metadata.size > MAX_STATE_BYTES) throw new Error("sharing state exceeds the 5 MiB read limit");
|
|
112
|
-
return normalizeSharing(JSON.parse(await readFile(path, "utf8")), root);
|
|
113
|
-
} catch (error) {
|
|
114
|
-
if (error.code !== "ENOENT") throw error;
|
|
115
|
-
return emptySharing(root);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function writeJson(path, state) {
|
|
120
|
-
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
121
|
-
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("sharing state exceeds 5 MiB; reject or delete old imports first");
|
|
122
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
123
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
124
|
-
try {
|
|
125
|
-
await replaceFileWithRetry(temporary, path);
|
|
126
|
-
} catch (error) {
|
|
127
|
-
await unlink(temporary).catch((cleanupError) => {
|
|
128
|
-
if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
|
|
129
|
-
});
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function acquireLock(path) {
|
|
135
|
-
const lockPath = `${path}.lock`;
|
|
136
|
-
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
137
|
-
try {
|
|
138
|
-
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
139
|
-
} catch (error) {
|
|
140
|
-
if (!isFileLockContention(error)) throw error;
|
|
141
|
-
try {
|
|
142
|
-
const metadata = await stat(lockPath);
|
|
143
|
-
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
144
|
-
} catch (lockError) {
|
|
145
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
146
|
-
}
|
|
147
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
throw new Error("sharing state is busy; retry shortly");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function withLock(path, read, task, save = true) {
|
|
154
|
-
const { handle, lockPath } = await acquireLock(path);
|
|
155
|
-
try {
|
|
156
|
-
const value = await read();
|
|
157
|
-
const result = await task(value);
|
|
158
|
-
if (save) await writeJson(path, value);
|
|
159
|
-
return result;
|
|
160
|
-
} finally {
|
|
161
|
-
await handle.close();
|
|
162
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function sharingPaths(root, providedCatalog = null) {
|
|
167
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
168
|
-
const directory = await projectStateDir(catalog.root);
|
|
169
|
-
return { catalog, sharingPath: join(directory, "sharing.json") };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function loadSharing(root = process.cwd(), providedCatalog = null) {
|
|
173
|
-
const paths = await sharingPaths(root, providedCatalog);
|
|
174
|
-
return { sharing: await readJson(paths.sharingPath, paths.catalog.root), ...paths };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export async function inspectSharing(root = process.cwd(), providedCatalog = null) {
|
|
178
|
-
const paths = await sharingPaths(root, providedCatalog);
|
|
179
|
-
try {
|
|
180
|
-
return { sharing: await readJson(paths.sharingPath, paths.catalog.root), error: null, ...paths };
|
|
181
|
-
} catch (error) {
|
|
182
|
-
return { sharing: emptySharing(paths.catalog.root), error: error.message, ...paths };
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function preserve(state, value, now) {
|
|
187
|
-
state.history.push({
|
|
188
|
-
kind: "shared-record",
|
|
189
|
-
recordId: value.event.id,
|
|
190
|
-
supersededAt: now,
|
|
191
|
-
privacy: value.event.privacy,
|
|
192
|
-
value: { ...value, authority: "context-only" },
|
|
193
|
-
authority: "context-only"
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function mutation(root, operation) {
|
|
198
|
-
const paths = await sharingPaths(root);
|
|
199
|
-
const { graph } = await loadGraph(paths.catalog.root, paths.catalog);
|
|
200
|
-
return withLock(
|
|
201
|
-
paths.sharingPath,
|
|
202
|
-
() => readJson(paths.sharingPath, paths.catalog.root),
|
|
203
|
-
async (state) => {
|
|
204
|
-
if (!validConfig(state.config)) throw new Error("sharing configuration is invalid; run the audit before using shared memory");
|
|
205
|
-
const findings = sharingFindings(state, graph);
|
|
206
|
-
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
207
|
-
const result = await operation(state, paths.catalog, paths.sharingPath, graph);
|
|
208
|
-
const resultFindings = sharingFindings(state, graph);
|
|
209
|
-
if (resultFindings.length) throw new Error(`sharing mutation failed closed: ${resultFindings.join(", ")}`);
|
|
210
|
-
return result;
|
|
211
|
-
}
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function requireConfirmation(confirmation) {
|
|
216
|
-
if (confirmation !== CONFIRMATION) throw new Error("shared adapter writes require explicit local owner confirmation");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function manifestBody({ adapterId, scopeId, createdAt }) {
|
|
220
|
-
return {
|
|
221
|
-
schema: "agentspine.directory-adapter/v1",
|
|
222
|
-
adapter: "directory",
|
|
223
|
-
adapterId,
|
|
224
|
-
scopeId,
|
|
225
|
-
createdAt,
|
|
226
|
-
authority: "context-only"
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function validateManifest(manifest) {
|
|
231
|
-
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)
|
|
232
|
-
|| !exactKeys(manifest, ["schema", "adapter", "adapterId", "scopeId", "createdAt", "authority", "digest"])
|
|
233
|
-
|| manifest.schema !== "agentspine.directory-adapter/v1" || manifest.adapter !== "directory"
|
|
234
|
-
|| !ID_RE.test(manifest.adapterId || "") || !ID_RE.test(manifest.scopeId || "")
|
|
235
|
-
|| manifest.authority !== "context-only" || !validDateValue(manifest.createdAt)
|
|
236
|
-
|| !DIGEST_RE.test(manifest.digest || "") || digest(withoutDigest(manifest)) !== manifest.digest) {
|
|
237
|
-
throw new Error("directory adapter manifest is invalid or has failed its integrity check");
|
|
238
|
-
}
|
|
239
|
-
return manifest;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function validateAuthentication(authentication) {
|
|
243
|
-
return authentication === null || authentication === undefined || (
|
|
244
|
-
exactKeys(authentication, ["mode", "signerId", "keyId", "signedAt", "verifiedAt", "signature", "authority"])
|
|
245
|
-
&& authentication.mode === "signed" && ID_RE.test(authentication.signerId || "")
|
|
246
|
-
&& ID_RE.test(authentication.keyId || "") && validDateValue(authentication.signedAt)
|
|
247
|
-
&& validDateValue(authentication.verifiedAt) && typeof authentication.signature === "string"
|
|
248
|
-
&& authentication.signature.length >= 80 && authentication.signature.length <= 96
|
|
249
|
-
&& authentication.authority === "context-only"
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function assertRegularFile(path, field) {
|
|
254
|
-
const metadata = await lstat(path);
|
|
255
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`${field} must be a regular file, not a symlink`);
|
|
256
|
-
return metadata;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async function adapterLocation(root, directory, create = false) {
|
|
260
|
-
if (!directory || typeof directory !== "string") throw new Error("adapter directory is required");
|
|
261
|
-
const target = resolve(directory);
|
|
262
|
-
if (isInside(root, target)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
263
|
-
if (create) {
|
|
264
|
-
let ancestor = target;
|
|
265
|
-
const missing = [];
|
|
266
|
-
while (true) {
|
|
267
|
-
try {
|
|
268
|
-
ancestor = await realpath(ancestor);
|
|
269
|
-
break;
|
|
270
|
-
} catch (error) {
|
|
271
|
-
if (error.code !== "ENOENT") throw error;
|
|
272
|
-
const parent = dirname(ancestor);
|
|
273
|
-
if (parent === ancestor) throw error;
|
|
274
|
-
missing.push(basename(ancestor));
|
|
275
|
-
ancestor = parent;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const prospective = resolve(ancestor, ...missing.reverse());
|
|
279
|
-
if (isInside(root, prospective)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
280
|
-
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
281
|
-
}
|
|
282
|
-
const canonicalDirectory = await realpath(target);
|
|
283
|
-
if (isInside(root, canonicalDirectory)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
284
|
-
return canonicalDirectory;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async function openAdapter(root, directory) {
|
|
288
|
-
const adapterDirectory = await adapterLocation(root, directory, false);
|
|
289
|
-
const manifestPath = join(adapterDirectory, ".agentspine-exchange.json");
|
|
290
|
-
const metadata = await assertRegularFile(manifestPath, "adapter manifest");
|
|
291
|
-
if (metadata.size > MAX_EVENT_BYTES) throw new Error("adapter manifest exceeds 64 KiB");
|
|
292
|
-
const manifestDocument = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
293
|
-
let manifest;
|
|
294
|
-
let authentication = null;
|
|
295
|
-
let publicIdentity = null;
|
|
296
|
-
if (manifestDocument?.schema === "agentspine.signed-envelope/v1") {
|
|
297
|
-
const verified = verifyEnvelope(manifestDocument, "manifest");
|
|
298
|
-
manifest = validateManifest(verified.payload);
|
|
299
|
-
authentication = verified.authentication;
|
|
300
|
-
publicIdentity = verified.publicIdentity;
|
|
301
|
-
} else {
|
|
302
|
-
manifest = validateManifest(manifestDocument);
|
|
303
|
-
}
|
|
304
|
-
const eventsDirectory = join(adapterDirectory, "events");
|
|
305
|
-
const eventsMetadata = await lstat(eventsDirectory);
|
|
306
|
-
if (!eventsMetadata.isDirectory() || eventsMetadata.isSymbolicLink()) throw new Error("adapter events path must be a real directory");
|
|
307
|
-
return {
|
|
308
|
-
adapterDirectory, manifestPath, manifest, manifestDocument,
|
|
309
|
-
eventsDirectory, authentication, publicIdentity
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export async function initDirectoryAdapter({
|
|
314
|
-
root = process.cwd(), directory, scopeId, adapterId = `adapter:${randomUUID()}`,
|
|
315
|
-
signerId = null, confirmation, now = new Date()
|
|
316
|
-
}) {
|
|
317
|
-
requireConfirmation(confirmation);
|
|
318
|
-
if (!ID_RE.test(scopeId || "")) throw new Error("scopeId must be a stable, whitespace-free identifier");
|
|
319
|
-
if (!ID_RE.test(adapterId || "")) throw new Error("adapterId must be a stable, whitespace-free identifier");
|
|
320
|
-
const catalog = await buildCatalog(root);
|
|
321
|
-
const adapterDirectory = await adapterLocation(catalog.root, directory, true);
|
|
322
|
-
const manifestPath = join(adapterDirectory, ".agentspine-exchange.json");
|
|
323
|
-
const eventsDirectory = join(adapterDirectory, "events");
|
|
324
|
-
await mkdir(eventsDirectory, { recursive: true, mode: 0o700 });
|
|
325
|
-
const lockTarget = join(adapterDirectory, ".agentspine-adapter");
|
|
326
|
-
return withLock(lockTarget, async () => ({}), async () => {
|
|
327
|
-
try {
|
|
328
|
-
await assertRegularFile(manifestPath, "adapter manifest");
|
|
329
|
-
const existingDocument = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
330
|
-
const verified = existingDocument?.schema === "agentspine.signed-envelope/v1"
|
|
331
|
-
? verifyEnvelope(existingDocument, "manifest") : null;
|
|
332
|
-
const existing = validateManifest(verified ? verified.payload : existingDocument);
|
|
333
|
-
if (existing.scopeId !== scopeId) throw new Error(`adapter already belongs to scope ${existing.scopeId}`);
|
|
334
|
-
if (signerId && verified?.publicIdentity.signerId !== signerId) throw new Error("adapter is signed by a different identity");
|
|
335
|
-
return {
|
|
336
|
-
created: false, manifest: existing, signed: Boolean(verified),
|
|
337
|
-
signer: verified?.publicIdentity || null, adapterDirectory, manifestPath
|
|
338
|
-
};
|
|
339
|
-
} catch (error) {
|
|
340
|
-
if (error.code !== "ENOENT") throw error;
|
|
341
|
-
}
|
|
342
|
-
const body = manifestBody({ adapterId, scopeId, createdAt: date(now, "now") });
|
|
343
|
-
const manifest = { ...body, digest: digest(body) };
|
|
344
|
-
const document = signerId
|
|
345
|
-
? await signEnvelope({ root: catalog.root, signerId, kind: "manifest", payload: manifest, now })
|
|
346
|
-
: manifest;
|
|
347
|
-
const temporary = `${manifestPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
348
|
-
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
|
|
349
|
-
await rename(temporary, manifestPath);
|
|
350
|
-
return {
|
|
351
|
-
created: true, manifest, signed: Boolean(signerId),
|
|
352
|
-
signer: document.signer || null, adapterDirectory, manifestPath
|
|
353
|
-
};
|
|
354
|
-
}, false);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function eventBody({
|
|
358
|
-
id, scopeId, originInstanceId, candidate, supersedesEventId, publishedAt
|
|
359
|
-
}) {
|
|
360
|
-
const proof = candidate.review || candidate.promotion;
|
|
361
|
-
return {
|
|
362
|
-
schema: "agentspine.shared-event/v1",
|
|
363
|
-
id,
|
|
364
|
-
scopeId,
|
|
365
|
-
originInstanceId,
|
|
366
|
-
kind: candidate.kind,
|
|
367
|
-
claim: candidate.claim,
|
|
368
|
-
subjectId: candidate.subjectId,
|
|
369
|
-
privacy: candidate.privacy,
|
|
370
|
-
groupId: candidate.groupId,
|
|
371
|
-
confidence: candidate.confidence,
|
|
372
|
-
source: {
|
|
373
|
-
type: "accepted-learning",
|
|
374
|
-
learningId: candidate.id,
|
|
375
|
-
acceptedAt: candidate.acceptedAt,
|
|
376
|
-
automatic: candidate.automatic,
|
|
377
|
-
evidenceCount: candidate.evidence.length,
|
|
378
|
-
reviewDigest: digest(proof || {})
|
|
379
|
-
},
|
|
380
|
-
supersedesEventId,
|
|
381
|
-
publishedAt,
|
|
382
|
-
authority: "context-only"
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export function validateSharedEvent(event, expectedScopeId = null) {
|
|
387
|
-
if (!event || typeof event !== "object" || Array.isArray(event)
|
|
388
|
-
|| !exactKeys(event, [
|
|
389
|
-
"schema", "id", "scopeId", "originInstanceId", "kind", "claim", "subjectId", "privacy",
|
|
390
|
-
"groupId", "confidence", "source", "supersedesEventId", "publishedAt", "authority", "digest"
|
|
391
|
-
])
|
|
392
|
-
|| !exactKeys(event.source, ["type", "learningId", "acceptedAt", "automatic", "evidenceCount", "reviewDigest"])
|
|
393
|
-
|| event.schema !== "agentspine.shared-event/v1" || !ID_RE.test(event.id || "")
|
|
394
|
-
|| !ID_RE.test(event.scopeId || "") || !ID_RE.test(event.originInstanceId || "")
|
|
395
|
-
|| (expectedScopeId !== null && event.scopeId !== expectedScopeId)
|
|
396
|
-
|| !KINDS.has(event.kind) || !PRIVACY.has(event.privacy)
|
|
397
|
-
|| typeof event.claim !== "string" || !event.claim.trim() || event.claim.length > 1000
|
|
398
|
-
|| (event.subjectId !== null && !ID_RE.test(event.subjectId || ""))
|
|
399
|
-
|| (event.privacy === "group" && !ID_RE.test(event.groupId || ""))
|
|
400
|
-
|| (event.privacy !== "group" && event.groupId !== null)
|
|
401
|
-
|| !Number.isFinite(event.confidence) || event.confidence < 0 || event.confidence > 1
|
|
402
|
-
|| event.source?.type !== "accepted-learning" || !ID_RE.test(event.source?.learningId || "")
|
|
403
|
-
|| !validDateValue(event.source?.acceptedAt)
|
|
404
|
-
|| typeof event.source?.automatic !== "boolean" || !Number.isInteger(event.source?.evidenceCount) || event.source.evidenceCount < 1
|
|
405
|
-
|| !DIGEST_RE.test(event.source?.reviewDigest || "")
|
|
406
|
-
|| (event.supersedesEventId !== null && !ID_RE.test(event.supersedesEventId || ""))
|
|
407
|
-
|| !validDateValue(event.publishedAt) || event.authority !== "context-only"
|
|
408
|
-
|| !DIGEST_RE.test(event.digest || "") || digest(withoutDigest(event)) !== event.digest) {
|
|
409
|
-
throw new Error(`shared event is invalid or has failed its integrity check: ${event?.id || "unknown"}`);
|
|
410
|
-
}
|
|
411
|
-
if (SECRET_RE.test(event.claim) || AUTHORITY_RE.test(event.claim)) throw new Error(`shared event contains unsafe context: ${event.id}`);
|
|
412
|
-
return event;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function eventFilename(id) {
|
|
416
|
-
return `${createHash("sha256").update(id).digest("hex")}.json`;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
async function readEventFile(path, scopeId) {
|
|
420
|
-
const metadata = await assertRegularFile(path, "shared event");
|
|
421
|
-
if (metadata.size > MAX_EVENT_BYTES) throw new Error("shared event exceeds 64 KiB");
|
|
422
|
-
const document = JSON.parse(await readFile(path, "utf8"));
|
|
423
|
-
if (document?.schema === "agentspine.signed-envelope/v1") {
|
|
424
|
-
const verified = verifyEnvelope(document, "event");
|
|
425
|
-
return {
|
|
426
|
-
event: validateSharedEvent(verified.payload, scopeId),
|
|
427
|
-
authentication: verified.authentication,
|
|
428
|
-
publicIdentity: verified.publicIdentity,
|
|
429
|
-
document
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
return { event: validateSharedEvent(document, scopeId), authentication: null, publicIdentity: null, document };
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export async function publishLearning({
|
|
436
|
-
root = process.cwd(), directory, learningId, eventId = `shared:${randomUUID()}`,
|
|
437
|
-
supersedesEventId = null, signerId = null, confirmation, now = new Date()
|
|
438
|
-
}) {
|
|
439
|
-
requireConfirmation(confirmation);
|
|
440
|
-
if (!ID_RE.test(learningId || "")) throw new Error("learningId is required");
|
|
441
|
-
if (!ID_RE.test(eventId || "")) throw new Error("eventId must be a stable, whitespace-free identifier");
|
|
442
|
-
if (supersedesEventId !== null && !ID_RE.test(supersedesEventId || "")) throw new Error("supersedesEventId is invalid");
|
|
443
|
-
const catalog = await buildCatalog(root);
|
|
444
|
-
const adapter = await openAdapter(catalog.root, directory);
|
|
445
|
-
if (adapter.authentication && !signerId) throw new Error("signed adapters require an explicit local signer");
|
|
446
|
-
if (!adapter.authentication && signerId) throw new Error("an unsigned adapter cannot accept signed events");
|
|
447
|
-
const { learning } = await loadLearning(catalog.root, catalog);
|
|
448
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
449
|
-
const learningIssues = learningFindings(learning, graph);
|
|
450
|
-
if (learningIssues.length) throw new Error(`learning state failed closed: ${learningIssues.join(", ")}`);
|
|
451
|
-
const candidate = learning.candidates.find((item) => item.id === learningId);
|
|
452
|
-
if (!candidate || candidate.status !== "accepted") throw new Error("only accepted learning can be published");
|
|
453
|
-
if (!PRIVACY.has(candidate.privacy)) throw new Error("private learning can never be published to a shared adapter");
|
|
454
|
-
safeClaim(candidate.claim);
|
|
455
|
-
let instanceId;
|
|
456
|
-
await mutation(catalog.root, (state) => { instanceId = state.instanceId; return { instanceId }; });
|
|
457
|
-
const body = eventBody({
|
|
458
|
-
id: eventId, scopeId: adapter.manifest.scopeId, originInstanceId: instanceId,
|
|
459
|
-
candidate, supersedesEventId, publishedAt: date(now, "now")
|
|
460
|
-
});
|
|
461
|
-
const event = { ...body, digest: digest(body) };
|
|
462
|
-
validateSharedEvent(event, adapter.manifest.scopeId);
|
|
463
|
-
if (supersedesEventId !== null) {
|
|
464
|
-
const previousPath = join(adapter.eventsDirectory, eventFilename(supersedesEventId));
|
|
465
|
-
const previous = (await readEventFile(previousPath, adapter.manifest.scopeId)).event;
|
|
466
|
-
if (previous.kind !== event.kind || previous.subjectId !== event.subjectId
|
|
467
|
-
|| previous.privacy !== event.privacy || previous.groupId !== event.groupId) {
|
|
468
|
-
throw new Error("a superseding shared event must keep kind, subject, and privacy scope");
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
const target = join(adapter.eventsDirectory, eventFilename(event.id));
|
|
472
|
-
const lockTarget = join(adapter.adapterDirectory, ".agentspine-adapter");
|
|
473
|
-
const document = signerId
|
|
474
|
-
? await signEnvelope({ root: catalog.root, signerId, kind: "event", payload: event, now })
|
|
475
|
-
: event;
|
|
476
|
-
return withLock(lockTarget, async () => ({}), async () => {
|
|
477
|
-
try {
|
|
478
|
-
const existing = await readEventFile(target, adapter.manifest.scopeId);
|
|
479
|
-
if (existing.event.digest !== event.digest) throw new Error(`shared event ID collision: ${event.id}`);
|
|
480
|
-
return {
|
|
481
|
-
created: false, event: existing.event, authentication: existing.authentication,
|
|
482
|
-
eventPath: target, manifest: adapter.manifest
|
|
483
|
-
};
|
|
484
|
-
} catch (error) {
|
|
485
|
-
if (error.code !== "ENOENT") throw error;
|
|
486
|
-
}
|
|
487
|
-
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
488
|
-
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
|
|
489
|
-
await rename(temporary, target);
|
|
490
|
-
return {
|
|
491
|
-
created: true, event,
|
|
492
|
-
authentication: document.schema === "agentspine.signed-envelope/v1"
|
|
493
|
-
? verifyEnvelope(document, "event").authentication : null,
|
|
494
|
-
eventPath: target, manifest: adapter.manifest
|
|
495
|
-
};
|
|
496
|
-
}, false);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
async function readAdapterEvents(adapter) {
|
|
500
|
-
const entries = await readdir(adapter.eventsDirectory, { withFileTypes: true });
|
|
501
|
-
const candidates = entries.filter((entry) => entry.name.endsWith(".json"));
|
|
502
|
-
if (candidates.length > MAX_ADAPTER_EVENTS) throw new Error(`adapter exceeds ${MAX_ADAPTER_EVENTS} events`);
|
|
503
|
-
if (candidates.some((entry) => !entry.isFile() || entry.isSymbolicLink())) throw new Error("adapter contains a non-regular shared event");
|
|
504
|
-
const events = [];
|
|
505
|
-
let totalBytes = 0;
|
|
506
|
-
for (const entry of candidates.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
507
|
-
const path = join(adapter.eventsDirectory, entry.name);
|
|
508
|
-
const metadata = await lstat(path);
|
|
509
|
-
totalBytes += metadata.size;
|
|
510
|
-
if (totalBytes > MAX_ADAPTER_BYTES) throw new Error("adapter event payload exceeds the 20 MiB pull limit");
|
|
511
|
-
const item = await readEventFile(path, adapter.manifest.scopeId);
|
|
512
|
-
if (entry.name !== eventFilename(item.event.id)) throw new Error(`shared event filename does not match its ID: ${item.event.id}`);
|
|
513
|
-
events.push(item);
|
|
514
|
-
}
|
|
515
|
-
const byId = new Map();
|
|
516
|
-
for (const item of events) {
|
|
517
|
-
const previous = byId.get(item.event.id);
|
|
518
|
-
if (previous && previous.event.digest !== item.event.digest) throw new Error(`shared event ID collision: ${item.event.id}`);
|
|
519
|
-
byId.set(item.event.id, item);
|
|
520
|
-
}
|
|
521
|
-
return [...byId.values()].sort((a, b) => a.event.publishedAt.localeCompare(b.event.publishedAt) || a.event.id.localeCompare(b.event.id));
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
export async function readDirectoryExchange({
|
|
525
|
-
root = process.cwd(), directory, requireAuthenticated = true
|
|
526
|
-
}) {
|
|
527
|
-
const catalog = await buildCatalog(root);
|
|
528
|
-
const adapter = await openAdapter(catalog.root, directory);
|
|
529
|
-
if (requireAuthenticated && !adapter.authentication) {
|
|
530
|
-
throw new Error("HTTPS snapshots require an authenticated directory adapter");
|
|
531
|
-
}
|
|
532
|
-
const events = await readAdapterEvents(adapter);
|
|
533
|
-
if (adapter.authentication && events.some((item) => !item.authentication)) {
|
|
534
|
-
throw new Error("signed adapters cannot contain unsigned events");
|
|
535
|
-
}
|
|
536
|
-
if (!adapter.authentication && events.some((item) => item.authentication)) {
|
|
537
|
-
throw new Error("unsigned adapters cannot mix signed events");
|
|
538
|
-
}
|
|
539
|
-
return {
|
|
540
|
-
root: catalog.root,
|
|
541
|
-
scopeId: adapter.manifest.scopeId,
|
|
542
|
-
adapterId: adapter.manifest.adapterId,
|
|
543
|
-
authenticated: Boolean(adapter.authentication),
|
|
544
|
-
manifest: structuredClone(adapter.manifestDocument),
|
|
545
|
-
events: events.map((item) => structuredClone(item.document))
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
export async function pullShared({ root = process.cwd(), directory, requireAuthenticated = false, now = new Date() }) {
|
|
550
|
-
const catalog = await buildCatalog(root);
|
|
551
|
-
const adapter = await openAdapter(catalog.root, directory);
|
|
552
|
-
if (requireAuthenticated && !adapter.authentication) throw new Error("adapter is not authenticated");
|
|
553
|
-
const { trust } = await loadTrust(catalog.root, catalog);
|
|
554
|
-
if (adapter.authentication) assertTrustedIdentity(adapter.publicIdentity, trust);
|
|
555
|
-
const events = await readAdapterEvents(adapter);
|
|
556
|
-
if (adapter.authentication && events.some((item) => !item.authentication)) {
|
|
557
|
-
throw new Error("signed adapters cannot contain unsigned events");
|
|
558
|
-
}
|
|
559
|
-
if (!adapter.authentication && events.some((item) => item.authentication)) {
|
|
560
|
-
throw new Error("unsigned adapters cannot mix signed events");
|
|
561
|
-
}
|
|
562
|
-
for (const item of events) if (item.authentication) assertTrustedIdentity(item.publicIdentity, trust);
|
|
563
|
-
const receivedAt = date(now, "now");
|
|
564
|
-
return mutation(catalog.root, (state, _catalog, sharingPath) => {
|
|
565
|
-
const known = new Map([
|
|
566
|
-
...state.records.map((record) => [record.event.id, record.event.digest]),
|
|
567
|
-
...state.history.map((entry) => [entry.value?.event?.id, entry.value?.event?.digest]).filter(([id]) => id)
|
|
568
|
-
]);
|
|
569
|
-
const imported = [];
|
|
570
|
-
const skipped = [];
|
|
571
|
-
for (const item of events) {
|
|
572
|
-
const event = item.event;
|
|
573
|
-
const knownDigest = known.get(event.id);
|
|
574
|
-
if (knownDigest && knownDigest !== event.digest) throw new Error(`shared event ID collision in local state: ${event.id}`);
|
|
575
|
-
if (knownDigest || event.originInstanceId === state.instanceId) {
|
|
576
|
-
skipped.push(event.id);
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
state.records.push({
|
|
580
|
-
event,
|
|
581
|
-
authentication: item.authentication ? { ...item.authentication, verifiedAt: receivedAt } : null,
|
|
582
|
-
adapterId: adapter.manifest.adapterId,
|
|
583
|
-
receivedAt,
|
|
584
|
-
status: "pending",
|
|
585
|
-
review: null,
|
|
586
|
-
acceptedAt: null,
|
|
587
|
-
supersededIds: [],
|
|
588
|
-
authority: "context-only"
|
|
589
|
-
});
|
|
590
|
-
known.set(event.id, event.digest);
|
|
591
|
-
imported.push(event.id);
|
|
592
|
-
}
|
|
593
|
-
state.records.sort((a, b) => a.event.id.localeCompare(b.event.id));
|
|
594
|
-
return {
|
|
595
|
-
imported, skipped, scopeId: adapter.manifest.scopeId, adapterId: adapter.manifest.adapterId,
|
|
596
|
-
authenticated: Boolean(adapter.authentication),
|
|
597
|
-
manifestSigner: adapter.authentication ? {
|
|
598
|
-
signerId: adapter.authentication.signerId, keyId: adapter.authentication.keyId,
|
|
599
|
-
authority: "context-only"
|
|
600
|
-
} : null,
|
|
601
|
-
eventSigners: [...new Map(events.filter((item) => item.authentication).map((item) => [
|
|
602
|
-
item.authentication.keyId,
|
|
603
|
-
{ signerId: item.authentication.signerId, keyId: item.authentication.keyId, authority: "context-only" }
|
|
604
|
-
])).values()],
|
|
605
|
-
sharingPath
|
|
606
|
-
};
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
export async function reviewShared({
|
|
611
|
-
root = process.cwd(), id, decision, reason, confirmedByUser = false, now = new Date()
|
|
612
|
-
}) {
|
|
613
|
-
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
614
|
-
if (!new Set(["accept", "reject"]).has(decision)) throw new Error("decision must be accept or reject");
|
|
615
|
-
const reviewReason = safeText(reason, "reason", 500);
|
|
616
|
-
const reviewedAt = date(now, "now");
|
|
617
|
-
const acceptedTrust = decision === "accept" ? (await loadTrust(root)).trust : null;
|
|
618
|
-
return mutation(root, (state, _catalog, sharingPath, graph) => {
|
|
619
|
-
const record = state.records.find((item) => item.event.id === id);
|
|
620
|
-
if (!record) throw new Error(`unknown shared event: ${id}`);
|
|
621
|
-
if (record.status !== "pending") throw new Error("only a pending shared event can be reviewed");
|
|
622
|
-
preserve(state, record, reviewedAt);
|
|
623
|
-
if (decision === "reject") {
|
|
624
|
-
const rejected = {
|
|
625
|
-
...record,
|
|
626
|
-
status: "rejected",
|
|
627
|
-
review: { decision, reason: reviewReason, confirmedByUser: false, reviewedAt, authority: "context-only" },
|
|
628
|
-
authority: "context-only"
|
|
629
|
-
};
|
|
630
|
-
state.records = state.records.map((item) => item.event.id === id ? rejected : item);
|
|
631
|
-
return { record: rejected, sharingPath };
|
|
632
|
-
}
|
|
633
|
-
if (!confirmedByUser) throw new Error("acceptance requires explicit local user confirmation");
|
|
634
|
-
if (record.authentication) {
|
|
635
|
-
const proofFindings = sharingAuthenticationFindings({ records: [record], history: [] }, acceptedTrust);
|
|
636
|
-
if (proofFindings.length) throw new Error(`signed shared context failed verification: ${proofFindings.join(", ")}`);
|
|
637
|
-
const trusted = acceptedTrust.records.find((item) => item.keyId === record.authentication.keyId);
|
|
638
|
-
if (!trusted || trusted.status !== "trusted" || trusted.signerId !== record.authentication.signerId) {
|
|
639
|
-
throw new Error("signed shared context must still have a trusted signer at local review time");
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
validateLocalScope(record.event, graph);
|
|
643
|
-
const newer = state.records.find((item) => item.status === "accepted" && item.event.supersedesEventId === id);
|
|
644
|
-
if (newer) throw new Error(`shared event is already superseded by accepted event: ${newer.event.id}`);
|
|
645
|
-
const prior = record.event.supersedesEventId
|
|
646
|
-
? state.records.find((item) => item.event.id === record.event.supersedesEventId)
|
|
647
|
-
: null;
|
|
648
|
-
if (record.event.supersedesEventId && prior?.status !== "accepted") {
|
|
649
|
-
throw new Error("a shared predecessor must be locally accepted before its replacement");
|
|
650
|
-
}
|
|
651
|
-
if (prior && (prior.event.kind !== record.event.kind || prior.event.subjectId !== record.event.subjectId
|
|
652
|
-
|| prior.event.privacy !== record.event.privacy || prior.event.groupId !== record.event.groupId)) {
|
|
653
|
-
throw new Error("a shared replacement must keep kind, subject, and privacy scope");
|
|
654
|
-
}
|
|
655
|
-
if (prior) {
|
|
656
|
-
preserve(state, prior, reviewedAt);
|
|
657
|
-
state.records = state.records.map((item) => item.event.id === prior.event.id
|
|
658
|
-
? { ...item, status: "superseded", authority: "context-only" }
|
|
659
|
-
: item);
|
|
660
|
-
}
|
|
661
|
-
const accepted = {
|
|
662
|
-
...record,
|
|
663
|
-
status: "accepted",
|
|
664
|
-
review: { decision, reason: reviewReason, confirmedByUser: true, reviewedAt, authority: "context-only" },
|
|
665
|
-
acceptedAt: reviewedAt,
|
|
666
|
-
supersededIds: prior ? [prior.event.id] : [],
|
|
667
|
-
authority: "context-only"
|
|
668
|
-
};
|
|
669
|
-
state.records = state.records.map((item) => item.event.id === id ? accepted : item);
|
|
670
|
-
return { record: accepted, sharingPath };
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
export async function rollbackShared({ root = process.cwd(), id, reason, now = new Date() }) {
|
|
675
|
-
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
676
|
-
const rollbackReason = safeText(reason, "reason", 500);
|
|
677
|
-
const rolledBackAt = date(now, "now");
|
|
678
|
-
return mutation(root, (state, _catalog, sharingPath) => {
|
|
679
|
-
const record = state.records.find((item) => item.event.id === id);
|
|
680
|
-
if (!record || record.status !== "accepted") throw new Error("only accepted shared context can be rolled back");
|
|
681
|
-
preserve(state, record, rolledBackAt);
|
|
682
|
-
const restored = [];
|
|
683
|
-
for (const previousId of record.supersededIds || []) {
|
|
684
|
-
const previous = state.records.find((item) => item.event.id === previousId && item.status === "superseded");
|
|
685
|
-
if (!previous) continue;
|
|
686
|
-
preserve(state, previous, rolledBackAt);
|
|
687
|
-
state.records = state.records.map((item) => item.event.id === previousId
|
|
688
|
-
? { ...item, status: "accepted", authority: "context-only" }
|
|
689
|
-
: item);
|
|
690
|
-
restored.push(previousId);
|
|
691
|
-
}
|
|
692
|
-
const rolledBack = {
|
|
693
|
-
...record,
|
|
694
|
-
status: "rolled-back",
|
|
695
|
-
rollback: { reason: rollbackReason, rolledBackAt, authority: "context-only" },
|
|
696
|
-
authority: "context-only"
|
|
697
|
-
};
|
|
698
|
-
state.records = state.records.map((item) => item.event.id === id ? rolledBack : item);
|
|
699
|
-
return { record: rolledBack, restored, sharingPath };
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
function groupAudience(graph, groupId, includePrivate) {
|
|
704
|
-
const ids = new Set();
|
|
705
|
-
if (!groupId) return ids;
|
|
706
|
-
ids.add(groupId);
|
|
707
|
-
for (const edge of graph.entityEdges) {
|
|
708
|
-
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
709
|
-
if (edge.to === groupId) ids.add(edge.from);
|
|
710
|
-
if (edge.from === groupId) ids.add(edge.to);
|
|
711
|
-
}
|
|
712
|
-
return ids;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function visible(record, entities, audience, includePrivate, groupId) {
|
|
716
|
-
const event = record.event;
|
|
717
|
-
if (event.privacy === "group" && (!groupId || event.groupId !== groupId)) return false;
|
|
718
|
-
const subject = event.subjectId ? entities.get(event.subjectId) : null;
|
|
719
|
-
if (subject?.privacy === "private" && !includePrivate) return false;
|
|
720
|
-
if (subject?.privacy === "group" && !audience.has(subject.id)) return false;
|
|
721
|
-
if (event.privacy === "group" && event.subjectId && !audience.has(event.subjectId)) return false;
|
|
722
|
-
return true;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
export async function sharedContext({
|
|
726
|
-
root = process.cwd(), scopeId = null, groupId = null, includePrivate = false,
|
|
727
|
-
kinds = null, subjectIds = null, maxItems = null, catalog: providedCatalog = null
|
|
728
|
-
} = {}) {
|
|
729
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
730
|
-
const { sharing } = await loadSharing(catalog.root, catalog);
|
|
731
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
732
|
-
const { trust } = await loadTrust(catalog.root, catalog);
|
|
733
|
-
const findings = sharingFindings(sharing, graph);
|
|
734
|
-
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
735
|
-
const authenticationFindings = sharingAuthenticationFindings(sharing, trust);
|
|
736
|
-
if (authenticationFindings.length) throw new Error(`shared authentication failed closed: ${authenticationFindings.join(", ")}`);
|
|
737
|
-
if (scopeId !== null && !ID_RE.test(scopeId || "")) throw new Error("scopeId is invalid");
|
|
738
|
-
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
739
|
-
if (groupId !== null) {
|
|
740
|
-
const group = entities.get(groupId);
|
|
741
|
-
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
742
|
-
}
|
|
743
|
-
const kindFilter = kinds === null ? null : new Set(kinds);
|
|
744
|
-
if (kindFilter && [...kindFilter].some((kind) => !KINDS.has(kind))) throw new Error("kinds contains an unsupported shared-memory kind");
|
|
745
|
-
const subjectFilter = subjectIds === null ? null : new Set(subjectIds);
|
|
746
|
-
const audience = groupAudience(graph, groupId, includePrivate);
|
|
747
|
-
const limit = maxItems === null ? sharing.config.maxContextItems : integer(maxItems, "maxItems", 0, 50);
|
|
748
|
-
const items = sharing.records
|
|
749
|
-
.filter((record) => record.status === "accepted")
|
|
750
|
-
.filter((record) => !scopeId || record.event.scopeId === scopeId)
|
|
751
|
-
.filter((record) => !kindFilter || kindFilter.has(record.event.kind))
|
|
752
|
-
.filter((record) => !subjectFilter || subjectFilter.has(record.event.subjectId))
|
|
753
|
-
.filter((record) => visible(record, entities, audience, includePrivate, groupId))
|
|
754
|
-
.sort((a, b) => b.event.confidence - a.event.confidence || b.acceptedAt.localeCompare(a.acceptedAt) || a.event.id.localeCompare(b.event.id))
|
|
755
|
-
.slice(0, limit)
|
|
756
|
-
.map((record) => ({
|
|
757
|
-
id: record.event.id,
|
|
758
|
-
scopeId: record.event.scopeId,
|
|
759
|
-
kind: record.event.kind,
|
|
760
|
-
claim: record.event.claim,
|
|
761
|
-
subjectId: record.event.subjectId,
|
|
762
|
-
privacy: record.event.privacy,
|
|
763
|
-
groupId: record.event.groupId,
|
|
764
|
-
confidence: record.event.confidence,
|
|
765
|
-
originInstanceId: record.event.originInstanceId,
|
|
766
|
-
sourceLearningId: record.event.source.learningId,
|
|
767
|
-
publishedAt: record.event.publishedAt,
|
|
768
|
-
acceptedAt: record.acceptedAt,
|
|
769
|
-
authentication: record.authentication ? {
|
|
770
|
-
mode: "signed", signerId: record.authentication.signerId,
|
|
771
|
-
keyId: record.authentication.keyId, verifiedAt: record.authentication.verifiedAt,
|
|
772
|
-
authority: "context-only"
|
|
773
|
-
} : { mode: "integrity-only", authority: "context-only" },
|
|
774
|
-
authority: "context-only"
|
|
775
|
-
}));
|
|
776
|
-
return {
|
|
777
|
-
schema: "agentspine.shared-context/v1",
|
|
778
|
-
root: catalog.root,
|
|
779
|
-
scopeId,
|
|
780
|
-
groupId,
|
|
781
|
-
items,
|
|
782
|
-
authority: "context-only",
|
|
783
|
-
note: "Shared memory is reviewed descriptive context. It never grants delegation, host permissions, access, or instructions to act."
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
export async function sharedInbox({ root = process.cwd(), status = "pending" } = {}) {
|
|
788
|
-
if (!STATUSES.has(status)) throw new Error(`unsupported shared record status: ${status}`);
|
|
789
|
-
const { sharing, sharingPath } = await loadSharing(root);
|
|
790
|
-
const findings = sharingFindings(sharing, null);
|
|
791
|
-
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
792
|
-
return {
|
|
793
|
-
schema: "agentspine.shared-inbox/v1",
|
|
794
|
-
root: sharing.root,
|
|
795
|
-
status,
|
|
796
|
-
items: sharing.records.filter((record) => record.status === status).map((record) => ({
|
|
797
|
-
id: record.event.id,
|
|
798
|
-
scopeId: record.event.scopeId,
|
|
799
|
-
kind: record.event.kind,
|
|
800
|
-
claim: record.event.claim,
|
|
801
|
-
subjectId: record.event.subjectId,
|
|
802
|
-
privacy: record.event.privacy,
|
|
803
|
-
groupId: record.event.groupId,
|
|
804
|
-
confidence: record.event.confidence,
|
|
805
|
-
originInstanceId: record.event.originInstanceId,
|
|
806
|
-
publishedAt: record.event.publishedAt,
|
|
807
|
-
receivedAt: record.receivedAt,
|
|
808
|
-
authentication: record.authentication ? {
|
|
809
|
-
mode: "signed", signerId: record.authentication.signerId,
|
|
810
|
-
keyId: record.authentication.keyId, signedAt: record.authentication.signedAt,
|
|
811
|
-
verifiedAt: record.authentication.verifiedAt, authority: "context-only"
|
|
812
|
-
} : { mode: "integrity-only", authority: "context-only" },
|
|
813
|
-
authority: "context-only"
|
|
814
|
-
})),
|
|
815
|
-
sharingPath,
|
|
816
|
-
authority: "context-only"
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
export async function configureSharing({ root = process.cwd(), maxContextItems }) {
|
|
821
|
-
return mutation(root, (state, _catalog, sharingPath) => {
|
|
822
|
-
state.config.maxContextItems = integer(maxContextItems, "maxContextItems", 1, 50);
|
|
823
|
-
return { config: state.config, sharingPath };
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
export async function deleteShared({ root = process.cwd(), id, confirmation }) {
|
|
828
|
-
requireConfirmation(confirmation);
|
|
829
|
-
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
830
|
-
return mutation(root, (state, _catalog, sharingPath) => {
|
|
831
|
-
const record = state.records.find((item) => item.event.id === id);
|
|
832
|
-
if (record?.status === "accepted" && record.supersededIds?.length) {
|
|
833
|
-
throw new Error("roll back accepted superseding shared context before permanent deletion");
|
|
834
|
-
}
|
|
835
|
-
if (state.records.some((item) => item.status === "accepted" && item.supersededIds?.includes(id))) {
|
|
836
|
-
throw new Error("roll back accepted superseding shared context before deleting its predecessor");
|
|
837
|
-
}
|
|
838
|
-
const existed = Boolean(record);
|
|
839
|
-
state.records = state.records.filter((item) => item.event.id !== id);
|
|
840
|
-
state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.event?.id !== id);
|
|
841
|
-
return { deleted: existed, id, sharingPath };
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
export function sharingFindings(sharing, graph = null) {
|
|
846
|
-
const findings = [];
|
|
847
|
-
if (!exactKeys(sharing, ["schema", "root", "instanceId", "config", "records", "history"])
|
|
848
|
-
|| !exactKeys(sharing.config, ["maxContextItems"])
|
|
849
|
-
|| sharing.schema !== "agentspine.sharing/v1" || !ID_RE.test(sharing.instanceId || "") || !validConfig(sharing.config)) findings.push("invalid-sharing-state");
|
|
850
|
-
if (new Set(sharing.records.map((record) => record.event?.id)).size !== sharing.records.length) findings.push("duplicate-shared-event");
|
|
851
|
-
const records = [
|
|
852
|
-
...sharing.records.map((record) => ({ record, current: true })),
|
|
853
|
-
...sharing.history.map((entry) => entry.value).filter(Boolean).map((record) => ({ record, current: false }))
|
|
854
|
-
];
|
|
855
|
-
for (const { record, current } of records) {
|
|
856
|
-
try { validateSharedEvent(record.event); } catch { findings.push(`invalid-shared-event:${record.event?.id || "unknown"}`); }
|
|
857
|
-
const allowedRecordKeys = new Set(["event", "authentication", "adapterId", "receivedAt", "status", "review", "acceptedAt", "supersededIds", "rollback", "authority"]);
|
|
858
|
-
if (Object.keys(record).some((key) => !allowedRecordKeys.has(key))
|
|
859
|
-
|| !STATUSES.has(record.status) || record.authority !== "context-only" || !ID_RE.test(record.adapterId || "")
|
|
860
|
-
|| !validDateValue(record.receivedAt) || !Array.isArray(record.supersededIds)
|
|
861
|
-
|| record.supersededIds.some((id) => !ID_RE.test(id || ""))) findings.push(`invalid-shared-record:${record.event?.id || "unknown"}`);
|
|
862
|
-
if (!validateAuthentication(record.authentication)) findings.push(`invalid-shared-authentication:${record.event?.id || "unknown"}`);
|
|
863
|
-
const nested = [record.review, record.rollback].filter(Boolean);
|
|
864
|
-
if (nested.some((item) => item.authority !== "context-only")) findings.push(`shared-authority:${record.event?.id || "unknown"}`);
|
|
865
|
-
if (nested.some((item) => SECRET_RE.test(item.reason || ""))) findings.push(`unsafe-shared-review:${record.event?.id || "unknown"}`);
|
|
866
|
-
const validReview = record.review === null || (
|
|
867
|
-
exactKeys(record.review, ["decision", "reason", "confirmedByUser", "reviewedAt", "authority"])
|
|
868
|
-
&& new Set(["accept", "reject"]).has(record.review.decision)
|
|
869
|
-
&& typeof record.review.reason === "string" && record.review.reason.length > 0
|
|
870
|
-
&& typeof record.review.confirmedByUser === "boolean"
|
|
871
|
-
&& validDateValue(record.review.reviewedAt)
|
|
872
|
-
&& record.review.authority === "context-only"
|
|
873
|
-
);
|
|
874
|
-
const validRollback = record.rollback === undefined || (
|
|
875
|
-
exactKeys(record.rollback, ["reason", "rolledBackAt", "authority"])
|
|
876
|
-
&& typeof record.rollback.reason === "string" && record.rollback.reason.length > 0
|
|
877
|
-
&& validDateValue(record.rollback.rolledBackAt)
|
|
878
|
-
&& record.rollback.authority === "context-only"
|
|
879
|
-
);
|
|
880
|
-
if (!validReview || !validRollback) findings.push(`invalid-shared-review:${record.event?.id || "unknown"}`);
|
|
881
|
-
if (record.status === "pending" && (record.review !== null || record.acceptedAt !== null || record.supersededIds.length)) findings.push(`invalid-shared-pending:${record.event?.id || "unknown"}`);
|
|
882
|
-
if (record.status === "rejected" && (record.review?.decision !== "reject" || record.review?.confirmedByUser !== false || record.acceptedAt !== null)) findings.push(`invalid-shared-rejection:${record.event?.id || "unknown"}`);
|
|
883
|
-
if (record.status === "accepted" && (record.review?.decision !== "accept" || record.review?.confirmedByUser !== true || !record.acceptedAt)) {
|
|
884
|
-
findings.push(`invalid-shared-acceptance:${record.event?.id || "unknown"}`);
|
|
885
|
-
}
|
|
886
|
-
if (["accepted", "superseded", "rolled-back"].includes(record.status) && !validDateValue(record.acceptedAt)) findings.push(`invalid-shared-accepted-date:${record.event?.id || "unknown"}`);
|
|
887
|
-
if (record.status === "rolled-back" && !record.rollback) findings.push(`invalid-shared-rollback:${record.event?.id || "unknown"}`);
|
|
888
|
-
if (graph && current && record.status === "accepted") {
|
|
889
|
-
const event = record.event || {};
|
|
890
|
-
const subject = event.subjectId ? graph.entities.find((entity) => entity.id === event.subjectId) : null;
|
|
891
|
-
if (event.subjectId && !subject) findings.push(`unknown-shared-subject:${event.id || "unknown"}`);
|
|
892
|
-
if (event.privacy === "group") {
|
|
893
|
-
const group = graph.entities.find((entity) => entity.id === event.groupId && entity.kind === "group");
|
|
894
|
-
const member = !event.subjectId || graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
895
|
-
(edge.from === event.subjectId && edge.to === event.groupId) || (edge.to === event.subjectId && edge.from === event.groupId)
|
|
896
|
-
));
|
|
897
|
-
if (!group || !member) findings.push(`invalid-shared-group:${event.id || "unknown"}`);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
const accepted = new Set(sharing.records.filter((record) => record.status === "accepted").map((record) => record.event.id));
|
|
902
|
-
for (const record of sharing.records.filter((item) => item.status === "accepted" && item.event.supersedesEventId)) {
|
|
903
|
-
if (accepted.has(record.event.supersedesEventId)) findings.push(`active-shared-supersession:${record.event.id}`);
|
|
904
|
-
}
|
|
905
|
-
for (const record of sharing.records) {
|
|
906
|
-
if (record.status === "accepted") {
|
|
907
|
-
const expected = record.event.supersedesEventId === null ? [] : [record.event.supersedesEventId];
|
|
908
|
-
if (record.supersededIds.length !== expected.length
|
|
909
|
-
|| record.supersededIds.some((id, index) => id !== expected[index])) {
|
|
910
|
-
findings.push(`invalid-shared-supersession:${record.event.id}`);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
if (record.status === "superseded" && !sharing.records.some(
|
|
914
|
-
(item) => item.status === "accepted" && item.supersededIds.includes(record.event.id)
|
|
915
|
-
)) findings.push(`orphaned-shared-supersession:${record.event.id}`);
|
|
916
|
-
}
|
|
917
|
-
if (sharing.history.some((entry) => !exactKeys(entry, ["kind", "recordId", "supersededAt", "privacy", "value", "authority"])
|
|
918
|
-
|| entry.kind !== "shared-record" || entry.recordId !== entry.value?.event?.id
|
|
919
|
-
|| entry.privacy !== entry.value?.event?.privacy || !validDateValue(entry.supersededAt)
|
|
920
|
-
|| entry.authority !== "context-only" || entry.value?.authority !== "context-only")) findings.push("invalid-sharing-history");
|
|
921
|
-
return [...new Set(findings)];
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
export function sharingAuthenticationFindings(sharing, trust) {
|
|
925
|
-
const findings = [];
|
|
926
|
-
const trustIssues = trustFindings(trust);
|
|
927
|
-
if (trustIssues.length) return trustIssues;
|
|
928
|
-
const records = [
|
|
929
|
-
...sharing.records.map((record) => ({ record, current: true })),
|
|
930
|
-
...sharing.history.map((entry) => entry.value).filter(Boolean).map((record) => ({ record, current: false }))
|
|
931
|
-
];
|
|
932
|
-
for (const { record, current } of records.filter((item) => item.record.authentication)) {
|
|
933
|
-
const trusted = trust.records.find((item) => item.keyId === record.authentication.keyId);
|
|
934
|
-
if (!trusted || trusted.signerId !== record.authentication.signerId) {
|
|
935
|
-
findings.push(`missing-shared-signing-key:${record.event.id}`);
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
try {
|
|
939
|
-
verifyEnvelope({
|
|
940
|
-
schema: "agentspine.signed-envelope/v1",
|
|
941
|
-
kind: "event",
|
|
942
|
-
signer: trusted.publicIdentity,
|
|
943
|
-
payload: record.event,
|
|
944
|
-
signedAt: record.authentication.signedAt,
|
|
945
|
-
authority: "context-only",
|
|
946
|
-
signature: record.authentication.signature
|
|
947
|
-
}, "event");
|
|
948
|
-
} catch {
|
|
949
|
-
findings.push(`invalid-stored-shared-signature:${record.event.id}`);
|
|
950
|
-
}
|
|
951
|
-
if (current && record.status === "accepted" && trusted.status !== "trusted") {
|
|
952
|
-
findings.push(`untrusted-accepted-shared-event:${record.event.id}`);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
return [...new Set(findings)];
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
function validateLocalScope(event, graph) {
|
|
959
|
-
if (event.subjectId && !graph.entities.some((entity) => entity.id === event.subjectId)) {
|
|
960
|
-
throw new Error(`unknown shared subject entity: ${event.subjectId}`);
|
|
961
|
-
}
|
|
962
|
-
if (event.privacy !== "group") return;
|
|
963
|
-
const group = graph.entities.find((entity) => entity.id === event.groupId && entity.kind === "group");
|
|
964
|
-
if (!group) throw new Error(`unknown shared group entity: ${event.groupId}`);
|
|
965
|
-
const member = !event.subjectId || graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
966
|
-
(edge.from === event.subjectId && edge.to === event.groupId) || (edge.to === event.subjectId && edge.from === event.groupId)
|
|
967
|
-
));
|
|
968
|
-
if (!member) throw new Error(`shared subject is not a visible member of group: ${event.groupId}`);
|
|
969
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, writeFile
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { buildCatalog } from "./catalog.js";
|
|
7
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
8
|
+
import { loadGraph } from "./graph.js";
|
|
9
|
+
import { learningFindings, loadLearning } from "./learning.js";
|
|
10
|
+
import { isInside, projectStateDir } from "./paths.js";
|
|
11
|
+
import {
|
|
12
|
+
assertTrustedIdentity, loadTrust, signEnvelope, trustFindings, verifyEnvelope
|
|
13
|
+
} from "./authentication.js";
|
|
14
|
+
|
|
15
|
+
const KINDS = new Set(["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"]);
|
|
16
|
+
const PRIVACY = new Set(["shared", "group"]);
|
|
17
|
+
const STATUSES = new Set(["pending", "accepted", "rejected", "superseded", "rolled-back"]);
|
|
18
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
19
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
20
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
21
|
+
const MAX_EVENT_BYTES = 64 * 1024;
|
|
22
|
+
const MAX_ADAPTER_EVENTS = 2000;
|
|
23
|
+
const MAX_ADAPTER_BYTES = 20 * 1024 * 1024;
|
|
24
|
+
const CONFIRMATION = "local-share-confirmed";
|
|
25
|
+
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,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/i;
|
|
26
|
+
const AUTHORITY_RE = /\b(?:user|agent|person|they|he|she|i|ich|wir|nutzer|benutzer).{0,60}\b(?:may|can|is allowed|is authorized|has|have|darf|berechtigt|hat|haben).{0,50}\b(?:admin(?:istrator)?|permissions?|rights?|authorization|production access|deploy|billing|spending|policy exception|bypass|zugang|rechte|berechtigung|produktion|abrechnung|ausnahme|umgehen)\b/i;
|
|
27
|
+
|
|
28
|
+
function canonical(value) {
|
|
29
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function digest(value) {
|
|
37
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withoutDigest(value) {
|
|
41
|
+
const { digest: _digest, ...rest } = value;
|
|
42
|
+
return rest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function exactKeys(value, expected) {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
47
|
+
const actual = Object.keys(value).sort();
|
|
48
|
+
const wanted = [...expected].sort();
|
|
49
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeText(value, field, maximum) {
|
|
53
|
+
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
54
|
+
const result = value.trim().slice(0, maximum);
|
|
55
|
+
if (!result) throw new Error(`${field} is required`);
|
|
56
|
+
if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter shared memory`);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeClaim(value) {
|
|
61
|
+
const result = safeText(value, "claim", 1000);
|
|
62
|
+
if (AUTHORITY_RE.test(result)) throw new Error("authority and access claims cannot enter shared memory");
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function date(value, field = "date") {
|
|
67
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
68
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
69
|
+
return parsed.toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validDateValue(value) {
|
|
73
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function integer(value, field, minimum, maximum) {
|
|
77
|
+
const result = Number(value);
|
|
78
|
+
if (!Number.isInteger(result) || result < minimum || result > maximum) throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emptySharing(root) {
|
|
83
|
+
return {
|
|
84
|
+
schema: "agentspine.sharing/v1",
|
|
85
|
+
root,
|
|
86
|
+
instanceId: `instance:${randomUUID()}`,
|
|
87
|
+
config: { maxContextItems: 12 },
|
|
88
|
+
records: [],
|
|
89
|
+
history: []
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeSharing(value, root) {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
95
|
+
|| value.schema !== "agentspine.sharing/v1" || value.root !== root
|
|
96
|
+
|| !ID_RE.test(value.instanceId || "") || !value.config || typeof value.config !== "object"
|
|
97
|
+
|| !Array.isArray(value.records) || !value.records.every((item) => item && typeof item === "object")
|
|
98
|
+
|| !Array.isArray(value.history) || !value.history.every((item) => item && typeof item === "object")) {
|
|
99
|
+
throw new Error("sharing state structure is invalid; run the audit before using shared memory");
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validConfig(config) {
|
|
105
|
+
return Number.isInteger(config?.maxContextItems) && config.maxContextItems >= 1 && config.maxContextItems <= 50;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readJson(path, root) {
|
|
109
|
+
try {
|
|
110
|
+
const metadata = await stat(path);
|
|
111
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("sharing state exceeds the 5 MiB read limit");
|
|
112
|
+
return normalizeSharing(JSON.parse(await readFile(path, "utf8")), root);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code !== "ENOENT") throw error;
|
|
115
|
+
return emptySharing(root);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function writeJson(path, state) {
|
|
120
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
121
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("sharing state exceeds 5 MiB; reject or delete old imports first");
|
|
122
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
123
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
124
|
+
try {
|
|
125
|
+
await replaceFileWithRetry(temporary, path);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await unlink(temporary).catch((cleanupError) => {
|
|
128
|
+
if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
|
|
129
|
+
});
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function acquireLock(path) {
|
|
135
|
+
const lockPath = `${path}.lock`;
|
|
136
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
137
|
+
try {
|
|
138
|
+
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!isFileLockContention(error)) throw error;
|
|
141
|
+
try {
|
|
142
|
+
const metadata = await stat(lockPath);
|
|
143
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
144
|
+
} catch (lockError) {
|
|
145
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
146
|
+
}
|
|
147
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
throw new Error("sharing state is busy; retry shortly");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function withLock(path, read, task, save = true) {
|
|
154
|
+
const { handle, lockPath } = await acquireLock(path);
|
|
155
|
+
try {
|
|
156
|
+
const value = await read();
|
|
157
|
+
const result = await task(value);
|
|
158
|
+
if (save) await writeJson(path, value);
|
|
159
|
+
return result;
|
|
160
|
+
} finally {
|
|
161
|
+
await handle.close();
|
|
162
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function sharingPaths(root, providedCatalog = null) {
|
|
167
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
168
|
+
const directory = await projectStateDir(catalog.root);
|
|
169
|
+
return { catalog, sharingPath: join(directory, "sharing.json") };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function loadSharing(root = process.cwd(), providedCatalog = null) {
|
|
173
|
+
const paths = await sharingPaths(root, providedCatalog);
|
|
174
|
+
return { sharing: await readJson(paths.sharingPath, paths.catalog.root), ...paths };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function inspectSharing(root = process.cwd(), providedCatalog = null) {
|
|
178
|
+
const paths = await sharingPaths(root, providedCatalog);
|
|
179
|
+
try {
|
|
180
|
+
return { sharing: await readJson(paths.sharingPath, paths.catalog.root), error: null, ...paths };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { sharing: emptySharing(paths.catalog.root), error: error.message, ...paths };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function preserve(state, value, now) {
|
|
187
|
+
state.history.push({
|
|
188
|
+
kind: "shared-record",
|
|
189
|
+
recordId: value.event.id,
|
|
190
|
+
supersededAt: now,
|
|
191
|
+
privacy: value.event.privacy,
|
|
192
|
+
value: { ...value, authority: "context-only" },
|
|
193
|
+
authority: "context-only"
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function mutation(root, operation) {
|
|
198
|
+
const paths = await sharingPaths(root);
|
|
199
|
+
const { graph } = await loadGraph(paths.catalog.root, paths.catalog);
|
|
200
|
+
return withLock(
|
|
201
|
+
paths.sharingPath,
|
|
202
|
+
() => readJson(paths.sharingPath, paths.catalog.root),
|
|
203
|
+
async (state) => {
|
|
204
|
+
if (!validConfig(state.config)) throw new Error("sharing configuration is invalid; run the audit before using shared memory");
|
|
205
|
+
const findings = sharingFindings(state, graph);
|
|
206
|
+
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
207
|
+
const result = await operation(state, paths.catalog, paths.sharingPath, graph);
|
|
208
|
+
const resultFindings = sharingFindings(state, graph);
|
|
209
|
+
if (resultFindings.length) throw new Error(`sharing mutation failed closed: ${resultFindings.join(", ")}`);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function requireConfirmation(confirmation) {
|
|
216
|
+
if (confirmation !== CONFIRMATION) throw new Error("shared adapter writes require explicit local owner confirmation");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function manifestBody({ adapterId, scopeId, createdAt }) {
|
|
220
|
+
return {
|
|
221
|
+
schema: "agentspine.directory-adapter/v1",
|
|
222
|
+
adapter: "directory",
|
|
223
|
+
adapterId,
|
|
224
|
+
scopeId,
|
|
225
|
+
createdAt,
|
|
226
|
+
authority: "context-only"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateManifest(manifest) {
|
|
231
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)
|
|
232
|
+
|| !exactKeys(manifest, ["schema", "adapter", "adapterId", "scopeId", "createdAt", "authority", "digest"])
|
|
233
|
+
|| manifest.schema !== "agentspine.directory-adapter/v1" || manifest.adapter !== "directory"
|
|
234
|
+
|| !ID_RE.test(manifest.adapterId || "") || !ID_RE.test(manifest.scopeId || "")
|
|
235
|
+
|| manifest.authority !== "context-only" || !validDateValue(manifest.createdAt)
|
|
236
|
+
|| !DIGEST_RE.test(manifest.digest || "") || digest(withoutDigest(manifest)) !== manifest.digest) {
|
|
237
|
+
throw new Error("directory adapter manifest is invalid or has failed its integrity check");
|
|
238
|
+
}
|
|
239
|
+
return manifest;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function validateAuthentication(authentication) {
|
|
243
|
+
return authentication === null || authentication === undefined || (
|
|
244
|
+
exactKeys(authentication, ["mode", "signerId", "keyId", "signedAt", "verifiedAt", "signature", "authority"])
|
|
245
|
+
&& authentication.mode === "signed" && ID_RE.test(authentication.signerId || "")
|
|
246
|
+
&& ID_RE.test(authentication.keyId || "") && validDateValue(authentication.signedAt)
|
|
247
|
+
&& validDateValue(authentication.verifiedAt) && typeof authentication.signature === "string"
|
|
248
|
+
&& authentication.signature.length >= 80 && authentication.signature.length <= 96
|
|
249
|
+
&& authentication.authority === "context-only"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function assertRegularFile(path, field) {
|
|
254
|
+
const metadata = await lstat(path);
|
|
255
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`${field} must be a regular file, not a symlink`);
|
|
256
|
+
return metadata;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function adapterLocation(root, directory, create = false) {
|
|
260
|
+
if (!directory || typeof directory !== "string") throw new Error("adapter directory is required");
|
|
261
|
+
const target = resolve(directory);
|
|
262
|
+
if (isInside(root, target)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
263
|
+
if (create) {
|
|
264
|
+
let ancestor = target;
|
|
265
|
+
const missing = [];
|
|
266
|
+
while (true) {
|
|
267
|
+
try {
|
|
268
|
+
ancestor = await realpath(ancestor);
|
|
269
|
+
break;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code !== "ENOENT") throw error;
|
|
272
|
+
const parent = dirname(ancestor);
|
|
273
|
+
if (parent === ancestor) throw error;
|
|
274
|
+
missing.push(basename(ancestor));
|
|
275
|
+
ancestor = parent;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const prospective = resolve(ancestor, ...missing.reverse());
|
|
279
|
+
if (isInside(root, prospective)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
280
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
281
|
+
}
|
|
282
|
+
const canonicalDirectory = await realpath(target);
|
|
283
|
+
if (isInside(root, canonicalDirectory)) throw new Error("shared adapter directory must remain outside the scanned project");
|
|
284
|
+
return canonicalDirectory;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function openAdapter(root, directory) {
|
|
288
|
+
const adapterDirectory = await adapterLocation(root, directory, false);
|
|
289
|
+
const manifestPath = join(adapterDirectory, ".agentspine-exchange.json");
|
|
290
|
+
const metadata = await assertRegularFile(manifestPath, "adapter manifest");
|
|
291
|
+
if (metadata.size > MAX_EVENT_BYTES) throw new Error("adapter manifest exceeds 64 KiB");
|
|
292
|
+
const manifestDocument = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
293
|
+
let manifest;
|
|
294
|
+
let authentication = null;
|
|
295
|
+
let publicIdentity = null;
|
|
296
|
+
if (manifestDocument?.schema === "agentspine.signed-envelope/v1") {
|
|
297
|
+
const verified = verifyEnvelope(manifestDocument, "manifest");
|
|
298
|
+
manifest = validateManifest(verified.payload);
|
|
299
|
+
authentication = verified.authentication;
|
|
300
|
+
publicIdentity = verified.publicIdentity;
|
|
301
|
+
} else {
|
|
302
|
+
manifest = validateManifest(manifestDocument);
|
|
303
|
+
}
|
|
304
|
+
const eventsDirectory = join(adapterDirectory, "events");
|
|
305
|
+
const eventsMetadata = await lstat(eventsDirectory);
|
|
306
|
+
if (!eventsMetadata.isDirectory() || eventsMetadata.isSymbolicLink()) throw new Error("adapter events path must be a real directory");
|
|
307
|
+
return {
|
|
308
|
+
adapterDirectory, manifestPath, manifest, manifestDocument,
|
|
309
|
+
eventsDirectory, authentication, publicIdentity
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function initDirectoryAdapter({
|
|
314
|
+
root = process.cwd(), directory, scopeId, adapterId = `adapter:${randomUUID()}`,
|
|
315
|
+
signerId = null, confirmation, now = new Date()
|
|
316
|
+
}) {
|
|
317
|
+
requireConfirmation(confirmation);
|
|
318
|
+
if (!ID_RE.test(scopeId || "")) throw new Error("scopeId must be a stable, whitespace-free identifier");
|
|
319
|
+
if (!ID_RE.test(adapterId || "")) throw new Error("adapterId must be a stable, whitespace-free identifier");
|
|
320
|
+
const catalog = await buildCatalog(root);
|
|
321
|
+
const adapterDirectory = await adapterLocation(catalog.root, directory, true);
|
|
322
|
+
const manifestPath = join(adapterDirectory, ".agentspine-exchange.json");
|
|
323
|
+
const eventsDirectory = join(adapterDirectory, "events");
|
|
324
|
+
await mkdir(eventsDirectory, { recursive: true, mode: 0o700 });
|
|
325
|
+
const lockTarget = join(adapterDirectory, ".agentspine-adapter");
|
|
326
|
+
return withLock(lockTarget, async () => ({}), async () => {
|
|
327
|
+
try {
|
|
328
|
+
await assertRegularFile(manifestPath, "adapter manifest");
|
|
329
|
+
const existingDocument = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
330
|
+
const verified = existingDocument?.schema === "agentspine.signed-envelope/v1"
|
|
331
|
+
? verifyEnvelope(existingDocument, "manifest") : null;
|
|
332
|
+
const existing = validateManifest(verified ? verified.payload : existingDocument);
|
|
333
|
+
if (existing.scopeId !== scopeId) throw new Error(`adapter already belongs to scope ${existing.scopeId}`);
|
|
334
|
+
if (signerId && verified?.publicIdentity.signerId !== signerId) throw new Error("adapter is signed by a different identity");
|
|
335
|
+
return {
|
|
336
|
+
created: false, manifest: existing, signed: Boolean(verified),
|
|
337
|
+
signer: verified?.publicIdentity || null, adapterDirectory, manifestPath
|
|
338
|
+
};
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error.code !== "ENOENT") throw error;
|
|
341
|
+
}
|
|
342
|
+
const body = manifestBody({ adapterId, scopeId, createdAt: date(now, "now") });
|
|
343
|
+
const manifest = { ...body, digest: digest(body) };
|
|
344
|
+
const document = signerId
|
|
345
|
+
? await signEnvelope({ root: catalog.root, signerId, kind: "manifest", payload: manifest, now })
|
|
346
|
+
: manifest;
|
|
347
|
+
const temporary = `${manifestPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
348
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
|
|
349
|
+
await rename(temporary, manifestPath);
|
|
350
|
+
return {
|
|
351
|
+
created: true, manifest, signed: Boolean(signerId),
|
|
352
|
+
signer: document.signer || null, adapterDirectory, manifestPath
|
|
353
|
+
};
|
|
354
|
+
}, false);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function eventBody({
|
|
358
|
+
id, scopeId, originInstanceId, candidate, supersedesEventId, publishedAt
|
|
359
|
+
}) {
|
|
360
|
+
const proof = candidate.review || candidate.promotion;
|
|
361
|
+
return {
|
|
362
|
+
schema: "agentspine.shared-event/v1",
|
|
363
|
+
id,
|
|
364
|
+
scopeId,
|
|
365
|
+
originInstanceId,
|
|
366
|
+
kind: candidate.kind,
|
|
367
|
+
claim: candidate.claim,
|
|
368
|
+
subjectId: candidate.subjectId,
|
|
369
|
+
privacy: candidate.privacy,
|
|
370
|
+
groupId: candidate.groupId,
|
|
371
|
+
confidence: candidate.confidence,
|
|
372
|
+
source: {
|
|
373
|
+
type: "accepted-learning",
|
|
374
|
+
learningId: candidate.id,
|
|
375
|
+
acceptedAt: candidate.acceptedAt,
|
|
376
|
+
automatic: candidate.automatic,
|
|
377
|
+
evidenceCount: candidate.evidence.length,
|
|
378
|
+
reviewDigest: digest(proof || {})
|
|
379
|
+
},
|
|
380
|
+
supersedesEventId,
|
|
381
|
+
publishedAt,
|
|
382
|
+
authority: "context-only"
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function validateSharedEvent(event, expectedScopeId = null) {
|
|
387
|
+
if (!event || typeof event !== "object" || Array.isArray(event)
|
|
388
|
+
|| !exactKeys(event, [
|
|
389
|
+
"schema", "id", "scopeId", "originInstanceId", "kind", "claim", "subjectId", "privacy",
|
|
390
|
+
"groupId", "confidence", "source", "supersedesEventId", "publishedAt", "authority", "digest"
|
|
391
|
+
])
|
|
392
|
+
|| !exactKeys(event.source, ["type", "learningId", "acceptedAt", "automatic", "evidenceCount", "reviewDigest"])
|
|
393
|
+
|| event.schema !== "agentspine.shared-event/v1" || !ID_RE.test(event.id || "")
|
|
394
|
+
|| !ID_RE.test(event.scopeId || "") || !ID_RE.test(event.originInstanceId || "")
|
|
395
|
+
|| (expectedScopeId !== null && event.scopeId !== expectedScopeId)
|
|
396
|
+
|| !KINDS.has(event.kind) || !PRIVACY.has(event.privacy)
|
|
397
|
+
|| typeof event.claim !== "string" || !event.claim.trim() || event.claim.length > 1000
|
|
398
|
+
|| (event.subjectId !== null && !ID_RE.test(event.subjectId || ""))
|
|
399
|
+
|| (event.privacy === "group" && !ID_RE.test(event.groupId || ""))
|
|
400
|
+
|| (event.privacy !== "group" && event.groupId !== null)
|
|
401
|
+
|| !Number.isFinite(event.confidence) || event.confidence < 0 || event.confidence > 1
|
|
402
|
+
|| event.source?.type !== "accepted-learning" || !ID_RE.test(event.source?.learningId || "")
|
|
403
|
+
|| !validDateValue(event.source?.acceptedAt)
|
|
404
|
+
|| typeof event.source?.automatic !== "boolean" || !Number.isInteger(event.source?.evidenceCount) || event.source.evidenceCount < 1
|
|
405
|
+
|| !DIGEST_RE.test(event.source?.reviewDigest || "")
|
|
406
|
+
|| (event.supersedesEventId !== null && !ID_RE.test(event.supersedesEventId || ""))
|
|
407
|
+
|| !validDateValue(event.publishedAt) || event.authority !== "context-only"
|
|
408
|
+
|| !DIGEST_RE.test(event.digest || "") || digest(withoutDigest(event)) !== event.digest) {
|
|
409
|
+
throw new Error(`shared event is invalid or has failed its integrity check: ${event?.id || "unknown"}`);
|
|
410
|
+
}
|
|
411
|
+
if (SECRET_RE.test(event.claim) || AUTHORITY_RE.test(event.claim)) throw new Error(`shared event contains unsafe context: ${event.id}`);
|
|
412
|
+
return event;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function eventFilename(id) {
|
|
416
|
+
return `${createHash("sha256").update(id).digest("hex")}.json`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function readEventFile(path, scopeId) {
|
|
420
|
+
const metadata = await assertRegularFile(path, "shared event");
|
|
421
|
+
if (metadata.size > MAX_EVENT_BYTES) throw new Error("shared event exceeds 64 KiB");
|
|
422
|
+
const document = JSON.parse(await readFile(path, "utf8"));
|
|
423
|
+
if (document?.schema === "agentspine.signed-envelope/v1") {
|
|
424
|
+
const verified = verifyEnvelope(document, "event");
|
|
425
|
+
return {
|
|
426
|
+
event: validateSharedEvent(verified.payload, scopeId),
|
|
427
|
+
authentication: verified.authentication,
|
|
428
|
+
publicIdentity: verified.publicIdentity,
|
|
429
|
+
document
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return { event: validateSharedEvent(document, scopeId), authentication: null, publicIdentity: null, document };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function publishLearning({
|
|
436
|
+
root = process.cwd(), directory, learningId, eventId = `shared:${randomUUID()}`,
|
|
437
|
+
supersedesEventId = null, signerId = null, confirmation, now = new Date()
|
|
438
|
+
}) {
|
|
439
|
+
requireConfirmation(confirmation);
|
|
440
|
+
if (!ID_RE.test(learningId || "")) throw new Error("learningId is required");
|
|
441
|
+
if (!ID_RE.test(eventId || "")) throw new Error("eventId must be a stable, whitespace-free identifier");
|
|
442
|
+
if (supersedesEventId !== null && !ID_RE.test(supersedesEventId || "")) throw new Error("supersedesEventId is invalid");
|
|
443
|
+
const catalog = await buildCatalog(root);
|
|
444
|
+
const adapter = await openAdapter(catalog.root, directory);
|
|
445
|
+
if (adapter.authentication && !signerId) throw new Error("signed adapters require an explicit local signer");
|
|
446
|
+
if (!adapter.authentication && signerId) throw new Error("an unsigned adapter cannot accept signed events");
|
|
447
|
+
const { learning } = await loadLearning(catalog.root, catalog);
|
|
448
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
449
|
+
const learningIssues = learningFindings(learning, graph);
|
|
450
|
+
if (learningIssues.length) throw new Error(`learning state failed closed: ${learningIssues.join(", ")}`);
|
|
451
|
+
const candidate = learning.candidates.find((item) => item.id === learningId);
|
|
452
|
+
if (!candidate || candidate.status !== "accepted") throw new Error("only accepted learning can be published");
|
|
453
|
+
if (!PRIVACY.has(candidate.privacy)) throw new Error("private learning can never be published to a shared adapter");
|
|
454
|
+
safeClaim(candidate.claim);
|
|
455
|
+
let instanceId;
|
|
456
|
+
await mutation(catalog.root, (state) => { instanceId = state.instanceId; return { instanceId }; });
|
|
457
|
+
const body = eventBody({
|
|
458
|
+
id: eventId, scopeId: adapter.manifest.scopeId, originInstanceId: instanceId,
|
|
459
|
+
candidate, supersedesEventId, publishedAt: date(now, "now")
|
|
460
|
+
});
|
|
461
|
+
const event = { ...body, digest: digest(body) };
|
|
462
|
+
validateSharedEvent(event, adapter.manifest.scopeId);
|
|
463
|
+
if (supersedesEventId !== null) {
|
|
464
|
+
const previousPath = join(adapter.eventsDirectory, eventFilename(supersedesEventId));
|
|
465
|
+
const previous = (await readEventFile(previousPath, adapter.manifest.scopeId)).event;
|
|
466
|
+
if (previous.kind !== event.kind || previous.subjectId !== event.subjectId
|
|
467
|
+
|| previous.privacy !== event.privacy || previous.groupId !== event.groupId) {
|
|
468
|
+
throw new Error("a superseding shared event must keep kind, subject, and privacy scope");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const target = join(adapter.eventsDirectory, eventFilename(event.id));
|
|
472
|
+
const lockTarget = join(adapter.adapterDirectory, ".agentspine-adapter");
|
|
473
|
+
const document = signerId
|
|
474
|
+
? await signEnvelope({ root: catalog.root, signerId, kind: "event", payload: event, now })
|
|
475
|
+
: event;
|
|
476
|
+
return withLock(lockTarget, async () => ({}), async () => {
|
|
477
|
+
try {
|
|
478
|
+
const existing = await readEventFile(target, adapter.manifest.scopeId);
|
|
479
|
+
if (existing.event.digest !== event.digest) throw new Error(`shared event ID collision: ${event.id}`);
|
|
480
|
+
return {
|
|
481
|
+
created: false, event: existing.event, authentication: existing.authentication,
|
|
482
|
+
eventPath: target, manifest: adapter.manifest
|
|
483
|
+
};
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error.code !== "ENOENT") throw error;
|
|
486
|
+
}
|
|
487
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
488
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
|
|
489
|
+
await rename(temporary, target);
|
|
490
|
+
return {
|
|
491
|
+
created: true, event,
|
|
492
|
+
authentication: document.schema === "agentspine.signed-envelope/v1"
|
|
493
|
+
? verifyEnvelope(document, "event").authentication : null,
|
|
494
|
+
eventPath: target, manifest: adapter.manifest
|
|
495
|
+
};
|
|
496
|
+
}, false);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function readAdapterEvents(adapter) {
|
|
500
|
+
const entries = await readdir(adapter.eventsDirectory, { withFileTypes: true });
|
|
501
|
+
const candidates = entries.filter((entry) => entry.name.endsWith(".json"));
|
|
502
|
+
if (candidates.length > MAX_ADAPTER_EVENTS) throw new Error(`adapter exceeds ${MAX_ADAPTER_EVENTS} events`);
|
|
503
|
+
if (candidates.some((entry) => !entry.isFile() || entry.isSymbolicLink())) throw new Error("adapter contains a non-regular shared event");
|
|
504
|
+
const events = [];
|
|
505
|
+
let totalBytes = 0;
|
|
506
|
+
for (const entry of candidates.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
507
|
+
const path = join(adapter.eventsDirectory, entry.name);
|
|
508
|
+
const metadata = await lstat(path);
|
|
509
|
+
totalBytes += metadata.size;
|
|
510
|
+
if (totalBytes > MAX_ADAPTER_BYTES) throw new Error("adapter event payload exceeds the 20 MiB pull limit");
|
|
511
|
+
const item = await readEventFile(path, adapter.manifest.scopeId);
|
|
512
|
+
if (entry.name !== eventFilename(item.event.id)) throw new Error(`shared event filename does not match its ID: ${item.event.id}`);
|
|
513
|
+
events.push(item);
|
|
514
|
+
}
|
|
515
|
+
const byId = new Map();
|
|
516
|
+
for (const item of events) {
|
|
517
|
+
const previous = byId.get(item.event.id);
|
|
518
|
+
if (previous && previous.event.digest !== item.event.digest) throw new Error(`shared event ID collision: ${item.event.id}`);
|
|
519
|
+
byId.set(item.event.id, item);
|
|
520
|
+
}
|
|
521
|
+
return [...byId.values()].sort((a, b) => a.event.publishedAt.localeCompare(b.event.publishedAt) || a.event.id.localeCompare(b.event.id));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function readDirectoryExchange({
|
|
525
|
+
root = process.cwd(), directory, requireAuthenticated = true
|
|
526
|
+
}) {
|
|
527
|
+
const catalog = await buildCatalog(root);
|
|
528
|
+
const adapter = await openAdapter(catalog.root, directory);
|
|
529
|
+
if (requireAuthenticated && !adapter.authentication) {
|
|
530
|
+
throw new Error("HTTPS snapshots require an authenticated directory adapter");
|
|
531
|
+
}
|
|
532
|
+
const events = await readAdapterEvents(adapter);
|
|
533
|
+
if (adapter.authentication && events.some((item) => !item.authentication)) {
|
|
534
|
+
throw new Error("signed adapters cannot contain unsigned events");
|
|
535
|
+
}
|
|
536
|
+
if (!adapter.authentication && events.some((item) => item.authentication)) {
|
|
537
|
+
throw new Error("unsigned adapters cannot mix signed events");
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
root: catalog.root,
|
|
541
|
+
scopeId: adapter.manifest.scopeId,
|
|
542
|
+
adapterId: adapter.manifest.adapterId,
|
|
543
|
+
authenticated: Boolean(adapter.authentication),
|
|
544
|
+
manifest: structuredClone(adapter.manifestDocument),
|
|
545
|
+
events: events.map((item) => structuredClone(item.document))
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function pullShared({ root = process.cwd(), directory, requireAuthenticated = false, now = new Date() }) {
|
|
550
|
+
const catalog = await buildCatalog(root);
|
|
551
|
+
const adapter = await openAdapter(catalog.root, directory);
|
|
552
|
+
if (requireAuthenticated && !adapter.authentication) throw new Error("adapter is not authenticated");
|
|
553
|
+
const { trust } = await loadTrust(catalog.root, catalog);
|
|
554
|
+
if (adapter.authentication) assertTrustedIdentity(adapter.publicIdentity, trust);
|
|
555
|
+
const events = await readAdapterEvents(adapter);
|
|
556
|
+
if (adapter.authentication && events.some((item) => !item.authentication)) {
|
|
557
|
+
throw new Error("signed adapters cannot contain unsigned events");
|
|
558
|
+
}
|
|
559
|
+
if (!adapter.authentication && events.some((item) => item.authentication)) {
|
|
560
|
+
throw new Error("unsigned adapters cannot mix signed events");
|
|
561
|
+
}
|
|
562
|
+
for (const item of events) if (item.authentication) assertTrustedIdentity(item.publicIdentity, trust);
|
|
563
|
+
const receivedAt = date(now, "now");
|
|
564
|
+
return mutation(catalog.root, (state, _catalog, sharingPath) => {
|
|
565
|
+
const known = new Map([
|
|
566
|
+
...state.records.map((record) => [record.event.id, record.event.digest]),
|
|
567
|
+
...state.history.map((entry) => [entry.value?.event?.id, entry.value?.event?.digest]).filter(([id]) => id)
|
|
568
|
+
]);
|
|
569
|
+
const imported = [];
|
|
570
|
+
const skipped = [];
|
|
571
|
+
for (const item of events) {
|
|
572
|
+
const event = item.event;
|
|
573
|
+
const knownDigest = known.get(event.id);
|
|
574
|
+
if (knownDigest && knownDigest !== event.digest) throw new Error(`shared event ID collision in local state: ${event.id}`);
|
|
575
|
+
if (knownDigest || event.originInstanceId === state.instanceId) {
|
|
576
|
+
skipped.push(event.id);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
state.records.push({
|
|
580
|
+
event,
|
|
581
|
+
authentication: item.authentication ? { ...item.authentication, verifiedAt: receivedAt } : null,
|
|
582
|
+
adapterId: adapter.manifest.adapterId,
|
|
583
|
+
receivedAt,
|
|
584
|
+
status: "pending",
|
|
585
|
+
review: null,
|
|
586
|
+
acceptedAt: null,
|
|
587
|
+
supersededIds: [],
|
|
588
|
+
authority: "context-only"
|
|
589
|
+
});
|
|
590
|
+
known.set(event.id, event.digest);
|
|
591
|
+
imported.push(event.id);
|
|
592
|
+
}
|
|
593
|
+
state.records.sort((a, b) => a.event.id.localeCompare(b.event.id));
|
|
594
|
+
return {
|
|
595
|
+
imported, skipped, scopeId: adapter.manifest.scopeId, adapterId: adapter.manifest.adapterId,
|
|
596
|
+
authenticated: Boolean(adapter.authentication),
|
|
597
|
+
manifestSigner: adapter.authentication ? {
|
|
598
|
+
signerId: adapter.authentication.signerId, keyId: adapter.authentication.keyId,
|
|
599
|
+
authority: "context-only"
|
|
600
|
+
} : null,
|
|
601
|
+
eventSigners: [...new Map(events.filter((item) => item.authentication).map((item) => [
|
|
602
|
+
item.authentication.keyId,
|
|
603
|
+
{ signerId: item.authentication.signerId, keyId: item.authentication.keyId, authority: "context-only" }
|
|
604
|
+
])).values()],
|
|
605
|
+
sharingPath
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function reviewShared({
|
|
611
|
+
root = process.cwd(), id, decision, reason, confirmedByUser = false, now = new Date()
|
|
612
|
+
}) {
|
|
613
|
+
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
614
|
+
if (!new Set(["accept", "reject"]).has(decision)) throw new Error("decision must be accept or reject");
|
|
615
|
+
const reviewReason = safeText(reason, "reason", 500);
|
|
616
|
+
const reviewedAt = date(now, "now");
|
|
617
|
+
const acceptedTrust = decision === "accept" ? (await loadTrust(root)).trust : null;
|
|
618
|
+
return mutation(root, (state, _catalog, sharingPath, graph) => {
|
|
619
|
+
const record = state.records.find((item) => item.event.id === id);
|
|
620
|
+
if (!record) throw new Error(`unknown shared event: ${id}`);
|
|
621
|
+
if (record.status !== "pending") throw new Error("only a pending shared event can be reviewed");
|
|
622
|
+
preserve(state, record, reviewedAt);
|
|
623
|
+
if (decision === "reject") {
|
|
624
|
+
const rejected = {
|
|
625
|
+
...record,
|
|
626
|
+
status: "rejected",
|
|
627
|
+
review: { decision, reason: reviewReason, confirmedByUser: false, reviewedAt, authority: "context-only" },
|
|
628
|
+
authority: "context-only"
|
|
629
|
+
};
|
|
630
|
+
state.records = state.records.map((item) => item.event.id === id ? rejected : item);
|
|
631
|
+
return { record: rejected, sharingPath };
|
|
632
|
+
}
|
|
633
|
+
if (!confirmedByUser) throw new Error("acceptance requires explicit local user confirmation");
|
|
634
|
+
if (record.authentication) {
|
|
635
|
+
const proofFindings = sharingAuthenticationFindings({ records: [record], history: [] }, acceptedTrust);
|
|
636
|
+
if (proofFindings.length) throw new Error(`signed shared context failed verification: ${proofFindings.join(", ")}`);
|
|
637
|
+
const trusted = acceptedTrust.records.find((item) => item.keyId === record.authentication.keyId);
|
|
638
|
+
if (!trusted || trusted.status !== "trusted" || trusted.signerId !== record.authentication.signerId) {
|
|
639
|
+
throw new Error("signed shared context must still have a trusted signer at local review time");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
validateLocalScope(record.event, graph);
|
|
643
|
+
const newer = state.records.find((item) => item.status === "accepted" && item.event.supersedesEventId === id);
|
|
644
|
+
if (newer) throw new Error(`shared event is already superseded by accepted event: ${newer.event.id}`);
|
|
645
|
+
const prior = record.event.supersedesEventId
|
|
646
|
+
? state.records.find((item) => item.event.id === record.event.supersedesEventId)
|
|
647
|
+
: null;
|
|
648
|
+
if (record.event.supersedesEventId && prior?.status !== "accepted") {
|
|
649
|
+
throw new Error("a shared predecessor must be locally accepted before its replacement");
|
|
650
|
+
}
|
|
651
|
+
if (prior && (prior.event.kind !== record.event.kind || prior.event.subjectId !== record.event.subjectId
|
|
652
|
+
|| prior.event.privacy !== record.event.privacy || prior.event.groupId !== record.event.groupId)) {
|
|
653
|
+
throw new Error("a shared replacement must keep kind, subject, and privacy scope");
|
|
654
|
+
}
|
|
655
|
+
if (prior) {
|
|
656
|
+
preserve(state, prior, reviewedAt);
|
|
657
|
+
state.records = state.records.map((item) => item.event.id === prior.event.id
|
|
658
|
+
? { ...item, status: "superseded", authority: "context-only" }
|
|
659
|
+
: item);
|
|
660
|
+
}
|
|
661
|
+
const accepted = {
|
|
662
|
+
...record,
|
|
663
|
+
status: "accepted",
|
|
664
|
+
review: { decision, reason: reviewReason, confirmedByUser: true, reviewedAt, authority: "context-only" },
|
|
665
|
+
acceptedAt: reviewedAt,
|
|
666
|
+
supersededIds: prior ? [prior.event.id] : [],
|
|
667
|
+
authority: "context-only"
|
|
668
|
+
};
|
|
669
|
+
state.records = state.records.map((item) => item.event.id === id ? accepted : item);
|
|
670
|
+
return { record: accepted, sharingPath };
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export async function rollbackShared({ root = process.cwd(), id, reason, now = new Date() }) {
|
|
675
|
+
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
676
|
+
const rollbackReason = safeText(reason, "reason", 500);
|
|
677
|
+
const rolledBackAt = date(now, "now");
|
|
678
|
+
return mutation(root, (state, _catalog, sharingPath) => {
|
|
679
|
+
const record = state.records.find((item) => item.event.id === id);
|
|
680
|
+
if (!record || record.status !== "accepted") throw new Error("only accepted shared context can be rolled back");
|
|
681
|
+
preserve(state, record, rolledBackAt);
|
|
682
|
+
const restored = [];
|
|
683
|
+
for (const previousId of record.supersededIds || []) {
|
|
684
|
+
const previous = state.records.find((item) => item.event.id === previousId && item.status === "superseded");
|
|
685
|
+
if (!previous) continue;
|
|
686
|
+
preserve(state, previous, rolledBackAt);
|
|
687
|
+
state.records = state.records.map((item) => item.event.id === previousId
|
|
688
|
+
? { ...item, status: "accepted", authority: "context-only" }
|
|
689
|
+
: item);
|
|
690
|
+
restored.push(previousId);
|
|
691
|
+
}
|
|
692
|
+
const rolledBack = {
|
|
693
|
+
...record,
|
|
694
|
+
status: "rolled-back",
|
|
695
|
+
rollback: { reason: rollbackReason, rolledBackAt, authority: "context-only" },
|
|
696
|
+
authority: "context-only"
|
|
697
|
+
};
|
|
698
|
+
state.records = state.records.map((item) => item.event.id === id ? rolledBack : item);
|
|
699
|
+
return { record: rolledBack, restored, sharingPath };
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function groupAudience(graph, groupId, includePrivate) {
|
|
704
|
+
const ids = new Set();
|
|
705
|
+
if (!groupId) return ids;
|
|
706
|
+
ids.add(groupId);
|
|
707
|
+
for (const edge of graph.entityEdges) {
|
|
708
|
+
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
709
|
+
if (edge.to === groupId) ids.add(edge.from);
|
|
710
|
+
if (edge.from === groupId) ids.add(edge.to);
|
|
711
|
+
}
|
|
712
|
+
return ids;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function visible(record, entities, audience, includePrivate, groupId) {
|
|
716
|
+
const event = record.event;
|
|
717
|
+
if (event.privacy === "group" && (!groupId || event.groupId !== groupId)) return false;
|
|
718
|
+
const subject = event.subjectId ? entities.get(event.subjectId) : null;
|
|
719
|
+
if (subject?.privacy === "private" && !includePrivate) return false;
|
|
720
|
+
if (subject?.privacy === "group" && !audience.has(subject.id)) return false;
|
|
721
|
+
if (event.privacy === "group" && event.subjectId && !audience.has(event.subjectId)) return false;
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export async function sharedContext({
|
|
726
|
+
root = process.cwd(), scopeId = null, groupId = null, includePrivate = false,
|
|
727
|
+
kinds = null, subjectIds = null, maxItems = null, catalog: providedCatalog = null
|
|
728
|
+
} = {}) {
|
|
729
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
730
|
+
const { sharing } = await loadSharing(catalog.root, catalog);
|
|
731
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
732
|
+
const { trust } = await loadTrust(catalog.root, catalog);
|
|
733
|
+
const findings = sharingFindings(sharing, graph);
|
|
734
|
+
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
735
|
+
const authenticationFindings = sharingAuthenticationFindings(sharing, trust);
|
|
736
|
+
if (authenticationFindings.length) throw new Error(`shared authentication failed closed: ${authenticationFindings.join(", ")}`);
|
|
737
|
+
if (scopeId !== null && !ID_RE.test(scopeId || "")) throw new Error("scopeId is invalid");
|
|
738
|
+
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
739
|
+
if (groupId !== null) {
|
|
740
|
+
const group = entities.get(groupId);
|
|
741
|
+
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
742
|
+
}
|
|
743
|
+
const kindFilter = kinds === null ? null : new Set(kinds);
|
|
744
|
+
if (kindFilter && [...kindFilter].some((kind) => !KINDS.has(kind))) throw new Error("kinds contains an unsupported shared-memory kind");
|
|
745
|
+
const subjectFilter = subjectIds === null ? null : new Set(subjectIds);
|
|
746
|
+
const audience = groupAudience(graph, groupId, includePrivate);
|
|
747
|
+
const limit = maxItems === null ? sharing.config.maxContextItems : integer(maxItems, "maxItems", 0, 50);
|
|
748
|
+
const items = sharing.records
|
|
749
|
+
.filter((record) => record.status === "accepted")
|
|
750
|
+
.filter((record) => !scopeId || record.event.scopeId === scopeId)
|
|
751
|
+
.filter((record) => !kindFilter || kindFilter.has(record.event.kind))
|
|
752
|
+
.filter((record) => !subjectFilter || subjectFilter.has(record.event.subjectId))
|
|
753
|
+
.filter((record) => visible(record, entities, audience, includePrivate, groupId))
|
|
754
|
+
.sort((a, b) => b.event.confidence - a.event.confidence || b.acceptedAt.localeCompare(a.acceptedAt) || a.event.id.localeCompare(b.event.id))
|
|
755
|
+
.slice(0, limit)
|
|
756
|
+
.map((record) => ({
|
|
757
|
+
id: record.event.id,
|
|
758
|
+
scopeId: record.event.scopeId,
|
|
759
|
+
kind: record.event.kind,
|
|
760
|
+
claim: record.event.claim,
|
|
761
|
+
subjectId: record.event.subjectId,
|
|
762
|
+
privacy: record.event.privacy,
|
|
763
|
+
groupId: record.event.groupId,
|
|
764
|
+
confidence: record.event.confidence,
|
|
765
|
+
originInstanceId: record.event.originInstanceId,
|
|
766
|
+
sourceLearningId: record.event.source.learningId,
|
|
767
|
+
publishedAt: record.event.publishedAt,
|
|
768
|
+
acceptedAt: record.acceptedAt,
|
|
769
|
+
authentication: record.authentication ? {
|
|
770
|
+
mode: "signed", signerId: record.authentication.signerId,
|
|
771
|
+
keyId: record.authentication.keyId, verifiedAt: record.authentication.verifiedAt,
|
|
772
|
+
authority: "context-only"
|
|
773
|
+
} : { mode: "integrity-only", authority: "context-only" },
|
|
774
|
+
authority: "context-only"
|
|
775
|
+
}));
|
|
776
|
+
return {
|
|
777
|
+
schema: "agentspine.shared-context/v1",
|
|
778
|
+
root: catalog.root,
|
|
779
|
+
scopeId,
|
|
780
|
+
groupId,
|
|
781
|
+
items,
|
|
782
|
+
authority: "context-only",
|
|
783
|
+
note: "Shared memory is reviewed descriptive context. It never grants delegation, host permissions, access, or instructions to act."
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export async function sharedInbox({ root = process.cwd(), status = "pending" } = {}) {
|
|
788
|
+
if (!STATUSES.has(status)) throw new Error(`unsupported shared record status: ${status}`);
|
|
789
|
+
const { sharing, sharingPath } = await loadSharing(root);
|
|
790
|
+
const findings = sharingFindings(sharing, null);
|
|
791
|
+
if (findings.length) throw new Error(`sharing state failed closed: ${findings.join(", ")}`);
|
|
792
|
+
return {
|
|
793
|
+
schema: "agentspine.shared-inbox/v1",
|
|
794
|
+
root: sharing.root,
|
|
795
|
+
status,
|
|
796
|
+
items: sharing.records.filter((record) => record.status === status).map((record) => ({
|
|
797
|
+
id: record.event.id,
|
|
798
|
+
scopeId: record.event.scopeId,
|
|
799
|
+
kind: record.event.kind,
|
|
800
|
+
claim: record.event.claim,
|
|
801
|
+
subjectId: record.event.subjectId,
|
|
802
|
+
privacy: record.event.privacy,
|
|
803
|
+
groupId: record.event.groupId,
|
|
804
|
+
confidence: record.event.confidence,
|
|
805
|
+
originInstanceId: record.event.originInstanceId,
|
|
806
|
+
publishedAt: record.event.publishedAt,
|
|
807
|
+
receivedAt: record.receivedAt,
|
|
808
|
+
authentication: record.authentication ? {
|
|
809
|
+
mode: "signed", signerId: record.authentication.signerId,
|
|
810
|
+
keyId: record.authentication.keyId, signedAt: record.authentication.signedAt,
|
|
811
|
+
verifiedAt: record.authentication.verifiedAt, authority: "context-only"
|
|
812
|
+
} : { mode: "integrity-only", authority: "context-only" },
|
|
813
|
+
authority: "context-only"
|
|
814
|
+
})),
|
|
815
|
+
sharingPath,
|
|
816
|
+
authority: "context-only"
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export async function configureSharing({ root = process.cwd(), maxContextItems }) {
|
|
821
|
+
return mutation(root, (state, _catalog, sharingPath) => {
|
|
822
|
+
state.config.maxContextItems = integer(maxContextItems, "maxContextItems", 1, 50);
|
|
823
|
+
return { config: state.config, sharingPath };
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export async function deleteShared({ root = process.cwd(), id, confirmation }) {
|
|
828
|
+
requireConfirmation(confirmation);
|
|
829
|
+
if (!ID_RE.test(id || "")) throw new Error("shared event id is required");
|
|
830
|
+
return mutation(root, (state, _catalog, sharingPath) => {
|
|
831
|
+
const record = state.records.find((item) => item.event.id === id);
|
|
832
|
+
if (record?.status === "accepted" && record.supersededIds?.length) {
|
|
833
|
+
throw new Error("roll back accepted superseding shared context before permanent deletion");
|
|
834
|
+
}
|
|
835
|
+
if (state.records.some((item) => item.status === "accepted" && item.supersededIds?.includes(id))) {
|
|
836
|
+
throw new Error("roll back accepted superseding shared context before deleting its predecessor");
|
|
837
|
+
}
|
|
838
|
+
const existed = Boolean(record);
|
|
839
|
+
state.records = state.records.filter((item) => item.event.id !== id);
|
|
840
|
+
state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.event?.id !== id);
|
|
841
|
+
return { deleted: existed, id, sharingPath };
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
export function sharingFindings(sharing, graph = null) {
|
|
846
|
+
const findings = [];
|
|
847
|
+
if (!exactKeys(sharing, ["schema", "root", "instanceId", "config", "records", "history"])
|
|
848
|
+
|| !exactKeys(sharing.config, ["maxContextItems"])
|
|
849
|
+
|| sharing.schema !== "agentspine.sharing/v1" || !ID_RE.test(sharing.instanceId || "") || !validConfig(sharing.config)) findings.push("invalid-sharing-state");
|
|
850
|
+
if (new Set(sharing.records.map((record) => record.event?.id)).size !== sharing.records.length) findings.push("duplicate-shared-event");
|
|
851
|
+
const records = [
|
|
852
|
+
...sharing.records.map((record) => ({ record, current: true })),
|
|
853
|
+
...sharing.history.map((entry) => entry.value).filter(Boolean).map((record) => ({ record, current: false }))
|
|
854
|
+
];
|
|
855
|
+
for (const { record, current } of records) {
|
|
856
|
+
try { validateSharedEvent(record.event); } catch { findings.push(`invalid-shared-event:${record.event?.id || "unknown"}`); }
|
|
857
|
+
const allowedRecordKeys = new Set(["event", "authentication", "adapterId", "receivedAt", "status", "review", "acceptedAt", "supersededIds", "rollback", "authority"]);
|
|
858
|
+
if (Object.keys(record).some((key) => !allowedRecordKeys.has(key))
|
|
859
|
+
|| !STATUSES.has(record.status) || record.authority !== "context-only" || !ID_RE.test(record.adapterId || "")
|
|
860
|
+
|| !validDateValue(record.receivedAt) || !Array.isArray(record.supersededIds)
|
|
861
|
+
|| record.supersededIds.some((id) => !ID_RE.test(id || ""))) findings.push(`invalid-shared-record:${record.event?.id || "unknown"}`);
|
|
862
|
+
if (!validateAuthentication(record.authentication)) findings.push(`invalid-shared-authentication:${record.event?.id || "unknown"}`);
|
|
863
|
+
const nested = [record.review, record.rollback].filter(Boolean);
|
|
864
|
+
if (nested.some((item) => item.authority !== "context-only")) findings.push(`shared-authority:${record.event?.id || "unknown"}`);
|
|
865
|
+
if (nested.some((item) => SECRET_RE.test(item.reason || ""))) findings.push(`unsafe-shared-review:${record.event?.id || "unknown"}`);
|
|
866
|
+
const validReview = record.review === null || (
|
|
867
|
+
exactKeys(record.review, ["decision", "reason", "confirmedByUser", "reviewedAt", "authority"])
|
|
868
|
+
&& new Set(["accept", "reject"]).has(record.review.decision)
|
|
869
|
+
&& typeof record.review.reason === "string" && record.review.reason.length > 0
|
|
870
|
+
&& typeof record.review.confirmedByUser === "boolean"
|
|
871
|
+
&& validDateValue(record.review.reviewedAt)
|
|
872
|
+
&& record.review.authority === "context-only"
|
|
873
|
+
);
|
|
874
|
+
const validRollback = record.rollback === undefined || (
|
|
875
|
+
exactKeys(record.rollback, ["reason", "rolledBackAt", "authority"])
|
|
876
|
+
&& typeof record.rollback.reason === "string" && record.rollback.reason.length > 0
|
|
877
|
+
&& validDateValue(record.rollback.rolledBackAt)
|
|
878
|
+
&& record.rollback.authority === "context-only"
|
|
879
|
+
);
|
|
880
|
+
if (!validReview || !validRollback) findings.push(`invalid-shared-review:${record.event?.id || "unknown"}`);
|
|
881
|
+
if (record.status === "pending" && (record.review !== null || record.acceptedAt !== null || record.supersededIds.length)) findings.push(`invalid-shared-pending:${record.event?.id || "unknown"}`);
|
|
882
|
+
if (record.status === "rejected" && (record.review?.decision !== "reject" || record.review?.confirmedByUser !== false || record.acceptedAt !== null)) findings.push(`invalid-shared-rejection:${record.event?.id || "unknown"}`);
|
|
883
|
+
if (record.status === "accepted" && (record.review?.decision !== "accept" || record.review?.confirmedByUser !== true || !record.acceptedAt)) {
|
|
884
|
+
findings.push(`invalid-shared-acceptance:${record.event?.id || "unknown"}`);
|
|
885
|
+
}
|
|
886
|
+
if (["accepted", "superseded", "rolled-back"].includes(record.status) && !validDateValue(record.acceptedAt)) findings.push(`invalid-shared-accepted-date:${record.event?.id || "unknown"}`);
|
|
887
|
+
if (record.status === "rolled-back" && !record.rollback) findings.push(`invalid-shared-rollback:${record.event?.id || "unknown"}`);
|
|
888
|
+
if (graph && current && record.status === "accepted") {
|
|
889
|
+
const event = record.event || {};
|
|
890
|
+
const subject = event.subjectId ? graph.entities.find((entity) => entity.id === event.subjectId) : null;
|
|
891
|
+
if (event.subjectId && !subject) findings.push(`unknown-shared-subject:${event.id || "unknown"}`);
|
|
892
|
+
if (event.privacy === "group") {
|
|
893
|
+
const group = graph.entities.find((entity) => entity.id === event.groupId && entity.kind === "group");
|
|
894
|
+
const member = !event.subjectId || graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
895
|
+
(edge.from === event.subjectId && edge.to === event.groupId) || (edge.to === event.subjectId && edge.from === event.groupId)
|
|
896
|
+
));
|
|
897
|
+
if (!group || !member) findings.push(`invalid-shared-group:${event.id || "unknown"}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const accepted = new Set(sharing.records.filter((record) => record.status === "accepted").map((record) => record.event.id));
|
|
902
|
+
for (const record of sharing.records.filter((item) => item.status === "accepted" && item.event.supersedesEventId)) {
|
|
903
|
+
if (accepted.has(record.event.supersedesEventId)) findings.push(`active-shared-supersession:${record.event.id}`);
|
|
904
|
+
}
|
|
905
|
+
for (const record of sharing.records) {
|
|
906
|
+
if (record.status === "accepted") {
|
|
907
|
+
const expected = record.event.supersedesEventId === null ? [] : [record.event.supersedesEventId];
|
|
908
|
+
if (record.supersededIds.length !== expected.length
|
|
909
|
+
|| record.supersededIds.some((id, index) => id !== expected[index])) {
|
|
910
|
+
findings.push(`invalid-shared-supersession:${record.event.id}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (record.status === "superseded" && !sharing.records.some(
|
|
914
|
+
(item) => item.status === "accepted" && item.supersededIds.includes(record.event.id)
|
|
915
|
+
)) findings.push(`orphaned-shared-supersession:${record.event.id}`);
|
|
916
|
+
}
|
|
917
|
+
if (sharing.history.some((entry) => !exactKeys(entry, ["kind", "recordId", "supersededAt", "privacy", "value", "authority"])
|
|
918
|
+
|| entry.kind !== "shared-record" || entry.recordId !== entry.value?.event?.id
|
|
919
|
+
|| entry.privacy !== entry.value?.event?.privacy || !validDateValue(entry.supersededAt)
|
|
920
|
+
|| entry.authority !== "context-only" || entry.value?.authority !== "context-only")) findings.push("invalid-sharing-history");
|
|
921
|
+
return [...new Set(findings)];
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function sharingAuthenticationFindings(sharing, trust) {
|
|
925
|
+
const findings = [];
|
|
926
|
+
const trustIssues = trustFindings(trust);
|
|
927
|
+
if (trustIssues.length) return trustIssues;
|
|
928
|
+
const records = [
|
|
929
|
+
...sharing.records.map((record) => ({ record, current: true })),
|
|
930
|
+
...sharing.history.map((entry) => entry.value).filter(Boolean).map((record) => ({ record, current: false }))
|
|
931
|
+
];
|
|
932
|
+
for (const { record, current } of records.filter((item) => item.record.authentication)) {
|
|
933
|
+
const trusted = trust.records.find((item) => item.keyId === record.authentication.keyId);
|
|
934
|
+
if (!trusted || trusted.signerId !== record.authentication.signerId) {
|
|
935
|
+
findings.push(`missing-shared-signing-key:${record.event.id}`);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
verifyEnvelope({
|
|
940
|
+
schema: "agentspine.signed-envelope/v1",
|
|
941
|
+
kind: "event",
|
|
942
|
+
signer: trusted.publicIdentity,
|
|
943
|
+
payload: record.event,
|
|
944
|
+
signedAt: record.authentication.signedAt,
|
|
945
|
+
authority: "context-only",
|
|
946
|
+
signature: record.authentication.signature
|
|
947
|
+
}, "event");
|
|
948
|
+
} catch {
|
|
949
|
+
findings.push(`invalid-stored-shared-signature:${record.event.id}`);
|
|
950
|
+
}
|
|
951
|
+
if (current && record.status === "accepted" && trusted.status !== "trusted") {
|
|
952
|
+
findings.push(`untrusted-accepted-shared-event:${record.event.id}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return [...new Set(findings)];
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function validateLocalScope(event, graph) {
|
|
959
|
+
if (event.subjectId && !graph.entities.some((entity) => entity.id === event.subjectId)) {
|
|
960
|
+
throw new Error(`unknown shared subject entity: ${event.subjectId}`);
|
|
961
|
+
}
|
|
962
|
+
if (event.privacy !== "group") return;
|
|
963
|
+
const group = graph.entities.find((entity) => entity.id === event.groupId && entity.kind === "group");
|
|
964
|
+
if (!group) throw new Error(`unknown shared group entity: ${event.groupId}`);
|
|
965
|
+
const member = !event.subjectId || graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
966
|
+
(edge.from === event.subjectId && edge.to === event.groupId) || (edge.to === event.subjectId && edge.from === event.groupId)
|
|
967
|
+
));
|
|
968
|
+
if (!member) throw new Error(`shared subject is not a visible member of group: ${event.groupId}`);
|
|
969
|
+
}
|