@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4
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/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +29 -0
package/server.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.dreamforgestudiollc/emet",
|
|
4
|
+
"title": "EMET",
|
|
5
|
+
"description": "Persistent shared memory for AI agents, with verified writes and a transcript tier.",
|
|
6
|
+
"version": "0.2.4",
|
|
7
|
+
"websiteUrl": "https://dreamforgestudiollc.github.io/EMET/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/dreamforgestudiollc/EMET",
|
|
10
|
+
"source": "github",
|
|
11
|
+
"id": "1402031262"
|
|
12
|
+
},
|
|
13
|
+
"icons": [
|
|
14
|
+
{
|
|
15
|
+
"src": "https://dreamforgestudiollc.github.io/EMET/assets/icon-48.png",
|
|
16
|
+
"mimeType": "image/png",
|
|
17
|
+
"sizes": ["48x48"]
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"packages": [
|
|
21
|
+
{
|
|
22
|
+
"registryType": "npm",
|
|
23
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
24
|
+
"identifier": "@dreamforgestudiollc/emet",
|
|
25
|
+
"version": "0.2.4",
|
|
26
|
+
"transport": {
|
|
27
|
+
"type": "stdio"
|
|
28
|
+
},
|
|
29
|
+
"environmentVariables": [
|
|
30
|
+
{
|
|
31
|
+
"name": "MONGODB_URI",
|
|
32
|
+
"description": "MongoDB connection string for the memory store.",
|
|
33
|
+
"isRequired": true,
|
|
34
|
+
"isSecret": true
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/aleph.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* aleph - the read-back verification layer.
|
|
8
|
+
*
|
|
9
|
+
* EMET is אמת, Hebrew for truth. In the golem legend the word is inscribed to
|
|
10
|
+
* animate the body, and erasing its first letter leaves מת - met, dead. One
|
|
11
|
+
* character is the whole difference.
|
|
12
|
+
*
|
|
13
|
+
* That letter is aleph, and this is the layer named for it. Every write is read
|
|
14
|
+
* back and compared before it is reported as saved. A memory system without that
|
|
15
|
+
* step still returns answers, still reports healthy, and is quietly a corpse:
|
|
16
|
+
* confidently serving things it never stored. Aleph is also the FIRST letter,
|
|
17
|
+
* which matches the order the protocol insists on - read back before trusting,
|
|
18
|
+
* verify before retrying, verify the premise before applying the remedy.
|
|
19
|
+
*
|
|
20
|
+
* The rule this enforces: **a write is not done because the call returned.**
|
|
21
|
+
* A driver reports what it sent, not what the store kept. The two disagree on
|
|
22
|
+
* a failed upsert, a wrong-collection write, a silently rejected field, and a
|
|
23
|
+
* duplicate-id collision - and every one of those is invisible to the caller
|
|
24
|
+
* unless someone goes and looks.
|
|
25
|
+
*
|
|
26
|
+
* A verification failure raises AlephError rather than a database error, because
|
|
27
|
+
* the two mean different things and lead to different actions. A DatabaseError
|
|
28
|
+
* means the store could not be reached and the write probably did not happen. An
|
|
29
|
+
* AlephError means the call SUCCEEDED and the store does not contain what it
|
|
30
|
+
* should - the more serious of the two, and the one that must never be retried
|
|
31
|
+
* blindly.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import crypto from 'crypto';
|
|
35
|
+
import { EmetError, ErrorCodes, StatusCodes } from './database.js';
|
|
36
|
+
|
|
37
|
+
/** sha256 of a string, hex. The canonical digest for everything EMET stores. */
|
|
38
|
+
export function digest(text) {
|
|
39
|
+
return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AlephError extends EmetError {
|
|
43
|
+
constructor(message, operation = 'unknown', details = {}) {
|
|
44
|
+
super(message, ErrorCodes.WRITE_ERROR, StatusCodes.INTERNAL_ERROR, details);
|
|
45
|
+
this.name = 'AlephError';
|
|
46
|
+
this.operation = operation;
|
|
47
|
+
this.verified = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
toSafeJSON() {
|
|
51
|
+
const base = super.toSafeJSON();
|
|
52
|
+
base.error.operation = this.operation;
|
|
53
|
+
base.error.verified = false;
|
|
54
|
+
return base;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verify a document write.
|
|
60
|
+
*
|
|
61
|
+
* Compares the digest AND the version. Digest alone would pass if a concurrent
|
|
62
|
+
* writer had stored identical content under a different version, which is not
|
|
63
|
+
* the same document state and must not be reported as this write succeeding.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} readBack the document as read back from the store
|
|
66
|
+
* @param {object} expected { doc_id, sha256, version }
|
|
67
|
+
* @returns {object} a receipt carrying verified: true
|
|
68
|
+
*/
|
|
69
|
+
export function verifyDocument(readBack, expected) {
|
|
70
|
+
const { doc_id, sha256, version } = expected;
|
|
71
|
+
|
|
72
|
+
if (!readBack) {
|
|
73
|
+
throw new AlephError(
|
|
74
|
+
`Read-back found no document at ${doc_id} immediately after writing it. The write reported ` +
|
|
75
|
+
`success and the store does not contain it.`,
|
|
76
|
+
'aleph.verifyDocument',
|
|
77
|
+
{ doc_id }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (readBack.sha256 !== sha256) {
|
|
81
|
+
throw new AlephError(
|
|
82
|
+
`Read-back of ${doc_id} does not match what was written: content digest differs. Do not ` +
|
|
83
|
+
`retry until the stored content has been inspected - a retry would overwrite whatever is ` +
|
|
84
|
+
`actually there.`,
|
|
85
|
+
'aleph.verifyDocument',
|
|
86
|
+
{ doc_id, expected_sha256: sha256, stored_sha256: readBack.sha256 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (version !== undefined && readBack.version !== version) {
|
|
90
|
+
throw new AlephError(
|
|
91
|
+
`Read-back of ${doc_id} is at version ${readBack.version}, not the ${version} this write ` +
|
|
92
|
+
`created. Another writer reached the document between the write and the read.`,
|
|
93
|
+
'aleph.verifyDocument',
|
|
94
|
+
{ doc_id, expected_version: version, stored_version: readBack.version }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return { verified: true, doc_id, sha256, version };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify a layer-entry write.
|
|
102
|
+
*
|
|
103
|
+
* The id matters as much as the content here. Ids are allocated from a counters
|
|
104
|
+
* collection, and a misconfigured prefix points that counter at an empty
|
|
105
|
+
* collection - so ids restart at 1 and collide with entries that already exist.
|
|
106
|
+
* The failure looks like a successful save and reads back as somebody else's
|
|
107
|
+
* memory, which is why this compares the CONTENT at the id rather than merely
|
|
108
|
+
* confirming that a record with that id exists.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} readBack the row as read back from the store
|
|
111
|
+
* @param {object} expected { id, content, layer }
|
|
112
|
+
*/
|
|
113
|
+
export function verifyLayerEntry(readBack, expected) {
|
|
114
|
+
const { id, content, layer } = expected;
|
|
115
|
+
|
|
116
|
+
if (!readBack) {
|
|
117
|
+
throw new AlephError(
|
|
118
|
+
`Read-back found no ${layer} entry at id ${id} immediately after writing it. The save ` +
|
|
119
|
+
`reported success and the store does not contain it.`,
|
|
120
|
+
'aleph.verifyLayerEntry',
|
|
121
|
+
{ layer, id }
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const wrote = digest(content);
|
|
125
|
+
const stored = digest(readBack.content !== undefined && readBack.content !== null
|
|
126
|
+
? readBack.content
|
|
127
|
+
: readBack.event);
|
|
128
|
+
|
|
129
|
+
// If the row carries its own sha256 column (written from 2026-09-05),
|
|
130
|
+
// it must agree with the content it sits beside. Rows written before the column
|
|
131
|
+
// existed carry none and are judged on content alone - absence is not a failure,
|
|
132
|
+
// but a PRESENT digest that disagrees is: the row is internally inconsistent.
|
|
133
|
+
const storedColumn = readBack.sha256 !== undefined && readBack.sha256 !== null ? String(readBack.sha256) : null;
|
|
134
|
+
if (storedColumn !== null && storedColumn !== stored) {
|
|
135
|
+
throw new AlephError(
|
|
136
|
+
`Read-back of ${layer} entry ${id} carries a sha256 column that does not match its own content. ` +
|
|
137
|
+
`The row is internally inconsistent - either the content or the digest was altered after the write.`,
|
|
138
|
+
'aleph.verifyLayerEntry',
|
|
139
|
+
{ layer, id, column_sha256: storedColumn, content_sha256: stored }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (stored !== wrote) {
|
|
144
|
+
throw new AlephError(
|
|
145
|
+
`Read-back of ${layer} entry ${id} returned different content than was written. The most ` +
|
|
146
|
+
`likely cause is an id collision: ids come from a counters collection, and a counter read ` +
|
|
147
|
+
`from the wrong collection restarts at 1 and overwrites existing entries. Check the ` +
|
|
148
|
+
`collection prefix before writing anything else to this store.`,
|
|
149
|
+
'aleph.verifyLayerEntry',
|
|
150
|
+
{ layer, id, expected_sha256: wrote, stored_sha256: stored }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// C1 (2026-09-07): the metadata column, when present, must agree with the
|
|
154
|
+
// metadata it sits beside, and with what the caller says it wrote. Same
|
|
155
|
+
// rule as the content column: absence is a dated boundary, disagreement is
|
|
156
|
+
// an inconsistency. When the caller supplies `expected.metadata`, the stored
|
|
157
|
+
// metadata must also digest to the same value - a row whose metadata was
|
|
158
|
+
// rewritten between the insert and the read-back is not the row that was
|
|
159
|
+
// attested.
|
|
160
|
+
let metadataDigestStored = false;
|
|
161
|
+
let metadataSha256 = null;
|
|
162
|
+
const metaColumn = readBack.metadata_sha256 !== undefined && readBack.metadata_sha256 !== null
|
|
163
|
+
? String(readBack.metadata_sha256) : null;
|
|
164
|
+
if (metaColumn !== null) {
|
|
165
|
+
const storedMeta = digestMetadata(readBack.metadata);
|
|
166
|
+
if (storedMeta !== metaColumn) {
|
|
167
|
+
throw new AlephError(
|
|
168
|
+
`Read-back of ${layer} entry ${id} carries a metadata_sha256 column that does not match its own metadata. ` +
|
|
169
|
+
`The row is internally inconsistent - either the metadata or its digest was altered after the write.`,
|
|
170
|
+
'aleph.verifyLayerEntry',
|
|
171
|
+
{ layer, id, column_metadata_sha256: metaColumn, metadata_sha256: storedMeta }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
metadataDigestStored = true;
|
|
175
|
+
metadataSha256 = metaColumn;
|
|
176
|
+
}
|
|
177
|
+
if (expected.metadata !== undefined) {
|
|
178
|
+
const wroteMeta = digestMetadata(expected.metadata);
|
|
179
|
+
const storedMeta = digestMetadata(readBack.metadata);
|
|
180
|
+
if (storedMeta !== wroteMeta) {
|
|
181
|
+
throw new AlephError(
|
|
182
|
+
`Read-back of ${layer} entry ${id} returned different metadata than was written.`,
|
|
183
|
+
'aleph.verifyLayerEntry',
|
|
184
|
+
{ layer, id, expected_metadata_sha256: wroteMeta, stored_metadata_sha256: storedMeta }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
metadataSha256 = wroteMeta;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
verified: true, layer, id, sha256: wrote, digest_stored: storedColumn !== null,
|
|
192
|
+
metadata_sha256: metadataSha256, metadata_digest_stored: metadataDigestStored
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Canonical JSON: keys sorted at every depth, no whitespace, so the same
|
|
198
|
+
* metadata produces the same bytes whatever order it was assembled in or
|
|
199
|
+
* returned in. undefined values and functions are dropped as JSON.stringify
|
|
200
|
+
* would drop them; a legacy metadata STRING (written before 2026-09-05) is parsed first so
|
|
201
|
+
* both stored shapes digest identically.
|
|
202
|
+
*/
|
|
203
|
+
export function canonicalJson(value) {
|
|
204
|
+
const norm = (v) => {
|
|
205
|
+
if (v === null || typeof v !== 'object') return v;
|
|
206
|
+
if (Array.isArray(v)) return v.map(norm);
|
|
207
|
+
if (v instanceof Date) return v.toISOString();
|
|
208
|
+
const out = {};
|
|
209
|
+
for (const k of Object.keys(v).sort()) {
|
|
210
|
+
if (v[k] === undefined || typeof v[k] === 'function') continue;
|
|
211
|
+
out[k] = norm(v[k]);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
return JSON.stringify(norm(value));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** sha256 of the canonical JSON of a metadata object (C1, 2026-09-07). */
|
|
219
|
+
export function digestMetadata(metadata) {
|
|
220
|
+
let md = metadata;
|
|
221
|
+
if (typeof md === 'string') { try { md = JSON.parse(md); } catch { /* digest the string as given */ } }
|
|
222
|
+
if (md === null || md === undefined) md = {};
|
|
223
|
+
return digest(canonicalJson(md));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Metadata integrity of a stored row at READ time (C1). Same three values as
|
|
228
|
+
* integrityOf: true / false / 'unattested' (no metadata_sha256 column).
|
|
229
|
+
*/
|
|
230
|
+
export function metadataIntegrityOf(row) {
|
|
231
|
+
if (!row || typeof row !== 'object') return 'unattested';
|
|
232
|
+
const stored = row.metadata_sha256;
|
|
233
|
+
if (typeof stored !== 'string' || stored.length === 0) return 'unattested';
|
|
234
|
+
return digestMetadata(row.metadata) === stored;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Integrity of a stored layer row at READ time (work list E2, 2026-09-07).
|
|
239
|
+
*
|
|
240
|
+
* verifyLayerEntry above runs once, at the write. The sha256 column it stores
|
|
241
|
+
* makes a second check possible every time the row is read, so a
|
|
242
|
+
* row altered after its write - by a migration, a hand edit, a bug - is
|
|
243
|
+
* flagged where it is used rather than discovered by accident. One hash per
|
|
244
|
+
* row; the cost is small and the alternative is trusting a receipt from the
|
|
245
|
+
* past.
|
|
246
|
+
*
|
|
247
|
+
* Returns:
|
|
248
|
+
* true - the content's digest equals the stored column
|
|
249
|
+
* false - the column exists and DISAGREES: the row is not what was
|
|
250
|
+
* attested at write. The row is still returned; the caller
|
|
251
|
+
* decides. Never silently dropped.
|
|
252
|
+
* 'unattested' - no sha256 column: written before 2026-09-05 (#12). Not a
|
|
253
|
+
* defect, a dated boundary; countable.
|
|
254
|
+
*
|
|
255
|
+
* The content column is `content`; older rows exposed it as `event` as well
|
|
256
|
+
* (both were written with the same value). Either is accepted, `content` first.
|
|
257
|
+
*/
|
|
258
|
+
export function integrityOf(row) {
|
|
259
|
+
if (!row || typeof row !== 'object') return 'unattested';
|
|
260
|
+
const stored = row.sha256;
|
|
261
|
+
if (typeof stored !== 'string' || stored.length === 0) return 'unattested';
|
|
262
|
+
const text = typeof row.content === 'string' ? row.content
|
|
263
|
+
: typeof row.event === 'string' ? row.event : null;
|
|
264
|
+
if (text === null) return false;
|
|
265
|
+
return digest(text) === stored;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Per-layer hash chain. Forward-only: rows without chain_sha256 are unlinked, not broken. */
|
|
269
|
+
export function chainDigest(prevSha256, contentSha256) {
|
|
270
|
+
return digest(`${prevSha256 || ''}\n${contentSha256}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function chainIntegrityOf(row) {
|
|
274
|
+
if (!row || typeof row !== 'object') return 'unlinked';
|
|
275
|
+
if (typeof row.chain_sha256 !== 'string' || !row.chain_sha256) return 'unlinked';
|
|
276
|
+
if (typeof row.sha256 !== 'string' || !row.sha256) return false;
|
|
277
|
+
return row.chain_sha256 === chainDigest(row.prev_sha256 || '', row.sha256);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk one layer's chain in id order (2026-09-29). chainIntegrityOf checks a
|
|
282
|
+
* row against ITSELF, so a row removed from the middle goes unseen while its
|
|
283
|
+
* neighbours stay self-consistent. At write time a row records as prev_sha256
|
|
284
|
+
* the sha256 of the highest-id row that carried one, so in id order every
|
|
285
|
+
* linked row's prev_sha256 must equal the sha256 of the nearest earlier row
|
|
286
|
+
* that has a sha256. When it does not, a row between them was removed, or two
|
|
287
|
+
* writes read the same head (a fork) - either way the record is no longer one
|
|
288
|
+
* line, and it is reported, never repaired.
|
|
289
|
+
*
|
|
290
|
+
* `rows` need {id, sha256, prev_sha256, chain_sha256}; order does not matter.
|
|
291
|
+
* `seedSha256`: the sha256 of the row just before the first one passed, when
|
|
292
|
+
* the walk covers only part of a layer.
|
|
293
|
+
* Returns counts, the live head, and up to `sample` ids per finding.
|
|
294
|
+
* Used by emet_gaps (every host) and scripts/chain-verify.mjs (the outside copy).
|
|
295
|
+
*/
|
|
296
|
+
export function walkChain(rows, { seedSha256 = null, sample = 20 } = {}) {
|
|
297
|
+
const sorted = [...(Array.isArray(rows) ? rows : [])].filter((r) => r && r.id !== undefined).sort((a, b) => a.id - b.id);
|
|
298
|
+
const out = { total: sorted.length, linked: 0, unlinked: 0, broken: 0, discontinuities: 0, broken_ids: [], discontinuity_ids: [], head: null };
|
|
299
|
+
let prevSha = seedSha256 || null;
|
|
300
|
+
for (const row of sorted) {
|
|
301
|
+
const v = chainIntegrityOf(row);
|
|
302
|
+
if (v === 'unlinked') out.unlinked++;
|
|
303
|
+
else if (v === false) { out.broken++; if (out.broken_ids.length < sample) out.broken_ids.push(row.id); }
|
|
304
|
+
else {
|
|
305
|
+
out.linked++;
|
|
306
|
+
out.head = { id: row.id, chain_sha256: row.chain_sha256, sha256: row.sha256, timestamp: row.timestamp ?? null };
|
|
307
|
+
if (prevSha !== null && (row.prev_sha256 || null) !== prevSha) {
|
|
308
|
+
out.discontinuities++;
|
|
309
|
+
if (out.discontinuity_ids.length < sample) out.discontinuity_ids.push(row.id);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (typeof row.sha256 === 'string' && row.sha256) prevSha = row.sha256;
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Payload the service signs: the two digests, not the raw text. */
|
|
318
|
+
export function signaturePayload(contentSha256, metadataSha256) {
|
|
319
|
+
return `${contentSha256}\n${metadataSha256 || ''}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** HMAC-SHA256 of the two digests. Key never stored in the database. */
|
|
323
|
+
export function signDigests(contentSha256, metadataSha256, key) {
|
|
324
|
+
if (!key) return null;
|
|
325
|
+
return crypto.createHmac('sha256', String(key))
|
|
326
|
+
.update(signaturePayload(contentSha256, metadataSha256), 'utf8')
|
|
327
|
+
.digest('hex');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* true - signature present and matches the stored digests under this key
|
|
332
|
+
* false - signature present and does not match (row was altered after sign, or wrong key)
|
|
333
|
+
* 'unsigned' - no signature column (written before this control)
|
|
334
|
+
* 'no_key' - service has no signing key configured; cannot judge
|
|
335
|
+
*/
|
|
336
|
+
export function signatureIntegrityOf(row, key) {
|
|
337
|
+
if (!row || typeof row !== 'object') return 'unsigned';
|
|
338
|
+
const sig = row.signature;
|
|
339
|
+
if (typeof sig !== 'string' || sig.length === 0) return 'unsigned';
|
|
340
|
+
if (!key) return 'no_key';
|
|
341
|
+
const expected = signDigests(row.sha256, row.metadata_sha256, key);
|
|
342
|
+
return expected === sig;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* May a row be RE-SIGNED after one of the sanctioned metadata marks? (2026-10-02,
|
|
347
|
+
* the reviewer's finding: the marks re-attested metadata_sha256 but left the
|
|
348
|
+
* signature over the old metadata digest, so every superseded or corrected
|
|
349
|
+
* row read signed:false.)
|
|
350
|
+
*
|
|
351
|
+
* Re-signing is never blind. It is allowed only when the row's EXISTING
|
|
352
|
+
* signature verifies, under this key, against the row's content digest and
|
|
353
|
+
* the metadata as it stood before the sanctioned marks, i.e. the stored
|
|
354
|
+
* metadata with only the back-pointers the server itself manages rolled back:
|
|
355
|
+
* - superseded_by / superseded_at: as stored, or absent;
|
|
356
|
+
* - corrected_by: as stored, or any shorter prefix of it, or absent
|
|
357
|
+
* ($addToSet appends, so every earlier list is a prefix of the current one).
|
|
358
|
+
* Anything else (no key, no signature, a signature from another key, or any
|
|
359
|
+
* other metadata field changed since it was signed) returns false, and the
|
|
360
|
+
* caller leaves the signature alone, so the row keeps reading signed:false
|
|
361
|
+
* and the change stays visible.
|
|
362
|
+
*/
|
|
363
|
+
export const SANCTIONED_MARK_FIELDS = ['superseded_by', 'superseded_at', 'corrected_by'];
|
|
364
|
+
export function signatureCoversPriorMetadata(row, key) {
|
|
365
|
+
if (!key || !row || typeof row !== 'object') return false;
|
|
366
|
+
const sig = row.signature;
|
|
367
|
+
if (typeof sig !== 'string' || sig.length === 0) return false;
|
|
368
|
+
if (typeof row.sha256 !== 'string' || row.sha256.length === 0) return false;
|
|
369
|
+
let md = row.metadata;
|
|
370
|
+
if (typeof md === 'string') { try { md = JSON.parse(md); } catch { md = null; } }
|
|
371
|
+
const ok = (candidate) => signDigests(row.sha256, digestMetadata(candidate), key) === sig;
|
|
372
|
+
// The signature already covers the stored metadata (nothing to roll back).
|
|
373
|
+
if (typeof row.metadata_sha256 === 'string' && signDigests(row.sha256, row.metadata_sha256, key) === sig
|
|
374
|
+
&& metadataIntegrityOf(row) === true) return true;
|
|
375
|
+
if (!md || typeof md !== 'object' || Array.isArray(md)) return false;
|
|
376
|
+
const supersededVariants = [md];
|
|
377
|
+
if ('superseded_by' in md || 'superseded_at' in md) {
|
|
378
|
+
const { superseded_by, superseded_at, ...rest } = md;
|
|
379
|
+
supersededVariants.push(rest);
|
|
380
|
+
}
|
|
381
|
+
for (const base of supersededVariants) {
|
|
382
|
+
if (ok(base)) return true;
|
|
383
|
+
if (Array.isArray(base.corrected_by)) {
|
|
384
|
+
const list = base.corrected_by;
|
|
385
|
+
for (let k = list.length - 1; k >= 0; k--) {
|
|
386
|
+
if (ok({ ...base, corrected_by: list.slice(0, k) })) return true;
|
|
387
|
+
}
|
|
388
|
+
const { corrected_by, ...withoutCorrected } = base;
|
|
389
|
+
if (ok(withoutCorrected)) return true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export default { digest, verifyDocument, verifyLayerEntry, integrityOf, canonicalJson, digestMetadata, metadataIntegrityOf, signDigests, signaturePayload, signatureIntegrityOf, signatureCoversPriorMetadata, SANCTIONED_MARK_FIELDS, AlephError };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* Tool annotations.
|
|
8
|
+
*
|
|
9
|
+
* MCP lets a server declare, per tool, what kind of operation it is. Hosts use
|
|
10
|
+
* these hints to decide what to approve automatically and what to put in front
|
|
11
|
+
* of a person. A server that declares nothing is treated as if every call might
|
|
12
|
+
* do anything, so hosts that ask before acting ask before EVERY call - including
|
|
13
|
+
* the reads that make up most of a session.
|
|
14
|
+
*
|
|
15
|
+
* These are hints, not enforcement. A host is free to ignore them, and a host
|
|
16
|
+
* that trusts them is trusting the server author. They are declared here, in one
|
|
17
|
+
* table rather than scattered through the tool definitions, because the honest
|
|
18
|
+
* version of this file is also its documentation: this is the complete statement
|
|
19
|
+
* of what EMET does to a store, and anyone deciding how much to auto-approve
|
|
20
|
+
* should be able to read it in one screen.
|
|
21
|
+
*
|
|
22
|
+
* The four hints, as the specification defines them:
|
|
23
|
+
* readOnlyHint - the tool does not modify anything.
|
|
24
|
+
* destructiveHint - the tool may replace or remove existing data (only
|
|
25
|
+
* meaningful when readOnlyHint is false).
|
|
26
|
+
* idempotentHint - calling it again with the same arguments changes nothing
|
|
27
|
+
* further.
|
|
28
|
+
* openWorldHint - the tool reaches systems outside this server's own store.
|
|
29
|
+
*
|
|
30
|
+
* openWorldHint is false everywhere. The tools talk to this server's own
|
|
31
|
+
* database and nothing else: no filesystem and no shell. The sign-in server
|
|
32
|
+
* fetches a client's metadata document, and only from a public address it has
|
|
33
|
+
* already checked. That is worth stating explicitly, because it is the main
|
|
34
|
+
* reason a memory server can be given standing permission when a shell server
|
|
35
|
+
* should not.
|
|
36
|
+
*
|
|
37
|
+
* WHY SO FEW TOOLS ARE MARKED DESTRUCTIVE: EMET's write model is additive by
|
|
38
|
+
* design. Layer entries are superseded rather than edited (supersede_never_mutate),
|
|
39
|
+
* and every document write archives the previous version to the history
|
|
40
|
+
* collection before replacing it. The replacing writes carry the flag, and so
|
|
41
|
+
* do the two doors that hand a new person access to the store: a recoverable
|
|
42
|
+
* overwrite is still an overwrite, and a code that grants access is not a
|
|
43
|
+
* harmless add.
|
|
44
|
+
*
|
|
45
|
+
* NOTHING IN THIS TABLE DELETES ANYTHING, and that is a design guarantee rather
|
|
46
|
+
* than an accident of the current tool set. Layer entries are superseded,
|
|
47
|
+
* documents are retired, transcripts are revised - every correction adds a
|
|
48
|
+
* record and marks the prior one obsolete. A host can read this table as the
|
|
49
|
+
* complete statement that EMET does not remove data.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
53
|
+
const ADD = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
54
|
+
const REPLACE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
55
|
+
|
|
56
|
+
export const TOOL_ANNOTATIONS = Object.freeze({
|
|
57
|
+
// --- Reads. Nothing in this group can change the store. -------------------
|
|
58
|
+
emet_initialize: { title: 'Start session', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
59
|
+
recall: { title: 'Search memory', ...READ },
|
|
60
|
+
semantic_recall: { title: 'Search memory by meaning', ...READ },
|
|
61
|
+
corpus_recall: { title: 'Search reference corpus', ...READ },
|
|
62
|
+
query_layer: { title: 'Query a memory layer', ...READ },
|
|
63
|
+
query_transcripts: { title: 'Read transcripts', ...READ },
|
|
64
|
+
read_doc: { title: 'Read a document', ...READ },
|
|
65
|
+
read_doc_history: { title: 'Read a retained document revision', ...READ },
|
|
66
|
+
list_docs: { title: 'List documents', ...READ },
|
|
67
|
+
emet_gaps: { title: 'Count the record\'s gaps', ...READ },
|
|
68
|
+
emet_floor: { title: 'Read the floor', ...READ },
|
|
69
|
+
// Checks a document against its template and writes nothing - not even the
|
|
70
|
+
// verdict. The write path stores that; this is the same check, borrowed.
|
|
71
|
+
validate_doc: { title: 'Check a document against its template', ...READ },
|
|
72
|
+
|
|
73
|
+
// --- Additive writes. These add; they do not remove or replace. -----------
|
|
74
|
+
// Was READ until 2026-09-27: "it does not write anything itself". Since the
|
|
75
|
+
// blocked-close cap: it records each blocked attempt in
|
|
76
|
+
// close_attempts, so a read-only hint would be untrue (code audit, 1.3).
|
|
77
|
+
emet_session_close: { title: 'Check session is saved', ...ADD },
|
|
78
|
+
emet_invite_create: { title: 'EMET Guest Access', ...REPLACE },
|
|
79
|
+
emet_member_access_create: { title: 'EMET Member Access', ...REPLACE },
|
|
80
|
+
save_to_layer: { title: 'Save to a memory layer', ...ADD },
|
|
81
|
+
revise_memory: { title: 'Supersede a memory', ...ADD },
|
|
82
|
+
save_transcript: { title: 'Save a transcript', ...ADD },
|
|
83
|
+
emet_session_open: { title: 'Open transcript session', ...ADD },
|
|
84
|
+
emet_transcript_append: { title: 'Append one transcript exchange', ...ADD },
|
|
85
|
+
revise_transcript: { title: 'Supersede a transcript part', ...ADD },
|
|
86
|
+
|
|
87
|
+
// --- Replacing writes. Prior versions are archived, but the visible
|
|
88
|
+
// content of an existing document is replaced. -------------------------
|
|
89
|
+
write_doc: { title: 'Write a document', ...REPLACE },
|
|
90
|
+
patch_doc: { title: 'Edit a document', ...REPLACE },
|
|
91
|
+
emet_setup_complete: { title: 'Complete setup', ...REPLACE },
|
|
92
|
+
|
|
93
|
+
// --- Obsolescence marks. NOT removals: this server has no delete verb for
|
|
94
|
+
// any store. The document keeps its id and its full content and read_doc
|
|
95
|
+
// still returns it, and list_docs keeps it after the current documents,
|
|
96
|
+
// marked with the date and what replaced it. Additive
|
|
97
|
+
// for the same reason revise_memory is - it marks, it does not destroy -
|
|
98
|
+
// and idempotent, because retiring an already-retired document reports
|
|
99
|
+
// the existing mark rather than making a second one. -------------------
|
|
100
|
+
retire_doc: { title: 'Mark a document obsolete', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
101
|
+
rename_doc: { title: 'Reissue a document under a new id', ...ADD },
|
|
102
|
+
restore_doc: { title: 'Write a retained revision back', ...REPLACE },
|
|
103
|
+
// Additive for the same reason retire_doc is: the nonconformance marker is
|
|
104
|
+
// kept and the acceptance is written beside it. Nothing is cleared, so
|
|
105
|
+
// accepting twice records the second reason rather than undoing anything.
|
|
106
|
+
accept_nonconformance: { title: 'Accept a nonconformance in writing', ...ADD },
|
|
107
|
+
|
|
108
|
+
// --- The one diagnostic. Reads by default; with probe: true it writes a
|
|
109
|
+
// probe document and removes it again, so it is not declared read-only -
|
|
110
|
+
// a health check that only reads cannot detect a broken write path.
|
|
111
|
+
// Replaced emet_setup_status, get_status and emet_verify_connection on
|
|
112
|
+
// 2026-09-27 (the owner: consolidate overlapping tools). -----------------------
|
|
113
|
+
emet_status: { title: 'Store status and write-path probe', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Attach annotations to a list of tool definitions.
|
|
118
|
+
*
|
|
119
|
+
* Throws on any tool with no entry in the table. A tool that reaches a host
|
|
120
|
+
* unannotated is silently treated as maximally dangerous, so the failure needs
|
|
121
|
+
* to happen here, at build time, rather than as unexplained approval prompts
|
|
122
|
+
* months later.
|
|
123
|
+
*/
|
|
124
|
+
export function annotate(tools) {
|
|
125
|
+
const missing = tools.filter((tool) => !TOOL_ANNOTATIONS[tool.name]).map((tool) => tool.name);
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Tools declared with no annotation entry: ${missing.join(', ')}. ` +
|
|
129
|
+
`Add them to TOOL_ANNOTATIONS in src/annotations.js.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return tools.map((tool) => ({ ...tool, annotations: TOOL_ANNOTATIONS[tool.name] }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default { TOOL_ANNOTATIONS, annotate };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* Throttle for the passphrase form (security audit finding H4, 2026-09-19).
|
|
8
|
+
*
|
|
9
|
+
* The tool-call rate limiter runs inside /mcp, behind bearer auth. Nothing
|
|
10
|
+
* limited POST /oauth/approve, so the one secret that gates every device could
|
|
11
|
+
* be guessed at network speed. This counts FAILED passphrase attempts per
|
|
12
|
+
* client address inside a sliding window and refuses further attempts once
|
|
13
|
+
* the count is reached, until the oldest failure ages out. A correct
|
|
14
|
+
* passphrase clears the address. In memory only: a restart forgets, which is
|
|
15
|
+
* acceptable for a form a person fills in by hand.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_MAX_FAILURES = 5;
|
|
19
|
+
export const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
|
|
20
|
+
const MAX_TRACKED = 10000;
|
|
21
|
+
|
|
22
|
+
export class ApproveThrottle {
|
|
23
|
+
constructor({ maxFailures = DEFAULT_MAX_FAILURES, windowMs = DEFAULT_WINDOW_MS, now = Date.now } = {}) {
|
|
24
|
+
this.maxFailures = maxFailures;
|
|
25
|
+
this.windowMs = windowMs;
|
|
26
|
+
this.now = now;
|
|
27
|
+
this.failures = new Map(); // ip -> [timestamps of failures inside the window]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_live(ip) {
|
|
31
|
+
const t = this.now();
|
|
32
|
+
const kept = (this.failures.get(ip) || []).filter((ts) => t - ts < this.windowMs);
|
|
33
|
+
if (kept.length) this.failures.set(ip, kept); else this.failures.delete(ip);
|
|
34
|
+
return kept;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @returns {{allowed: boolean, retryAfterS: number}} */
|
|
38
|
+
check(ip) {
|
|
39
|
+
const kept = this._live(String(ip));
|
|
40
|
+
if (kept.length < this.maxFailures) return { allowed: true, retryAfterS: 0 };
|
|
41
|
+
const retryAfterS = Math.max(1, Math.ceil((kept[0] + this.windowMs - this.now()) / 1000));
|
|
42
|
+
return { allowed: false, retryAfterS };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
recordFailure(ip) {
|
|
46
|
+
const key = String(ip);
|
|
47
|
+
const kept = this._live(key);
|
|
48
|
+
kept.push(this.now());
|
|
49
|
+
this.failures.set(key, kept);
|
|
50
|
+
if (this.failures.size > MAX_TRACKED) this.failures.delete(this.failures.keys().next().value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
recordSuccess(ip) { this.failures.delete(String(ip)); }
|
|
54
|
+
}
|