create-evda 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
@@ -0,0 +1,287 @@
1
+ // The gate a client goes through before it sends a hosted EVÐA anything: a record, a circle key, a
2
+ // token. (HQ-2083, Spec 08 section 8: "If it doesn't match, your phone won't send anything.")
3
+ //
4
+ // lib/tdx-attestation.mjs answers "does this quote prove this VM?". This file is what makes that answer
5
+ // decide whether bytes leave the device. A client gets a payload to a hosted EVÐA only through
6
+ // `gate.open(...)` and then `session.send(...)`, and every path that is not a verified quote under a
7
+ // pinned measurement ends in a refusal with nothing sent.
8
+ //
9
+ // The protocol, version 1. Over TLS to the host, on one connection:
10
+ // client -> host POST /.well-known/evda-attestation {"v":1,"nonce":"<base64, 32 bytes>"}
11
+ // host -> client {"v":1,"platform":"tdx","quote":"<base64>","collateral":{tcbInfo,
12
+ // tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain},
13
+ // "wrapKey":"<base64, 32 bytes, optional>"}
14
+ // The VM puts reportDataFor(nonce, its TLS public key) in the quote's REPORTDATA. The client takes the
15
+ // TLS key from the connection it is holding, never from the response body, so a host cannot name a key
16
+ // other than the one it is actually serving with. Delivery then goes over a connection pinned to that
17
+ // key (SHA-256 of its SPKI DER); a connection presenting any other key is refused before anything is
18
+ // written to it.
19
+ //
20
+ // A host that will receive circle keys also names `wrapKey`, the X25519 public key it made inside the
21
+ // VM, and binds it into REPORTDATA beside the nonce and TLS key (reportDataFor's second form). The
22
+ // client has to take that key from the body, so the binding is what makes it trustworthy: a key the
23
+ // quote does not bind is refused as NOT_BOUND. lib/attested-wrap.mjs wraps circle keys to it.
24
+ //
25
+ // The pinned measurement is the client's own configuration, never something a host supplies. With no
26
+ // pinned measurement the gate refuses: an unconfigured client is not a client that trusts everyone.
27
+ //
28
+ // Not here yet: tying the pinned measurement to EVÐA's Sigstore log (lib/measurement-log.mjs checks a
29
+ // logged digest; the statement that carries MRTD and RTMRs is a later slice), SEV-SNP, and the Swift
30
+ // port of this gate in iris-mobile, which follows this file and its test vectors.
31
+
32
+ import { createHash, createPublicKey, KeyObject, timingSafeEqual, X509Certificate } from "node:crypto";
33
+ import { request as httpsRequest } from "node:https";
34
+ import { createNonceBook, MEASUREMENT_NAMES, TDX_TRUST, verifyTdxAttestation, parseTdxQuote } from "./tdx-attestation.mjs";
35
+
36
+ /** Where a hosted EVÐA answers the attestation challenge. */
37
+ export const ATTESTATION_PATH = "/.well-known/evda-attestation";
38
+
39
+ /** The protocol version this client speaks. A response naming another version is refused. */
40
+ export const ATTESTATION_PROTOCOL_VERSION = 1;
41
+
42
+ /** Why the gate refused, as a stable code. `message` carries the same thing for a person. */
43
+ export const REFUSAL = Object.freeze({
44
+ NO_POLICY: "no-policy",
45
+ UNREACHABLE: "unreachable",
46
+ NO_QUOTE: "no-quote",
47
+ UNREADABLE_QUOTE: "unreadable-quote",
48
+ WRONG_MEASUREMENT: "wrong-measurement",
49
+ NOT_FRESH: "not-fresh",
50
+ NOT_BOUND: "not-bound",
51
+ NOT_PROVEN: "not-proven",
52
+ WRONG_CONNECTION: "wrong-connection",
53
+ NOT_DELIVERED: "not-delivered",
54
+ NO_WRAP_KEY: "no-wrap-key",
55
+ NOTHING_TO_WRAP: "nothing-to-wrap",
56
+ });
57
+
58
+ // What a person reads. Each says that nothing was sent and why, in words that need no TDX.
59
+ const SAID = {
60
+ [REFUSAL.NO_POLICY]: "this app has no published EVÐA code to compare the server against, so it cannot tell whether the server is safe",
61
+ [REFUSAL.UNREACHABLE]: "the server could not be reached to ask it for proof",
62
+ [REFUSAL.NO_QUOTE]: "the server did not send any proof of what code it is running",
63
+ [REFUSAL.UNREADABLE_QUOTE]: "the server's proof could not be read",
64
+ [REFUSAL.WRONG_MEASUREMENT]: "the server is not running the code EVÐA publishes",
65
+ [REFUSAL.NOT_FRESH]: "the server's proof answered a request this phone did not just make, so it could be an old proof played back",
66
+ [REFUSAL.NOT_BOUND]: "the server's proof was not made for this request on this connection, so it could be an old proof played back or someone in the middle",
67
+ [REFUSAL.NOT_PROVEN]: "the server could not prove it is a locked, up to date part of the chip",
68
+ [REFUSAL.WRONG_CONNECTION]: "the connection is not the one the server proved, so someone else could be in the middle",
69
+ [REFUSAL.NOT_DELIVERED]: "the server was proven, and sending to it then failed",
70
+ [REFUSAL.NO_WRAP_KEY]: "the server did not prove it made a key of its own to receive your keys",
71
+ [REFUSAL.NOTHING_TO_WRAP]: "no keys were named to send",
72
+ };
73
+
74
+ const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
75
+
76
+ /** A refusal: nothing was sent. `reason` is the detail for a log, `message` the sentence for a person. */
77
+ export function refuse(code, reason) {
78
+ return { ok: false, sent: false, code, reason, message: `Nothing was sent: ${SAID[code]}.` };
79
+ }
80
+
81
+ const spkiDer = (key) => {
82
+ if (Buffer.isBuffer(key) || key instanceof Uint8Array) return createPublicKey({ key: Buffer.from(key), format: "der", type: "spki" }).export({ type: "spki", format: "der" });
83
+ if (key instanceof KeyObject && key.type === "public") return key.export({ type: "spki", format: "der" });
84
+ return createPublicKey(key).export({ type: "spki", format: "der" });
85
+ };
86
+
87
+ /** The pin for a TLS public key: SHA-256 of its SPKI DER, lowercase hex. The same value the verifier returns. */
88
+ export function tlsKeyPin(tlsPublicKey) {
89
+ return sha256Hex(spkiDer(tlsPublicKey));
90
+ }
91
+
92
+ /** Whether `tlsPublicKey` is the key `pin` names. False for anything unreadable. */
93
+ export function pinMatches(pin, tlsPublicKey) {
94
+ try {
95
+ const have = Buffer.from(tlsKeyPin(tlsPublicKey), "hex");
96
+ const want = Buffer.from(String(pin ?? ""), "hex");
97
+ return want.length === 32 && timingSafeEqual(have, want);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Why `policy` cannot gate anything, in words, or null when it can.
105
+ * policy: { platform: "tdx", measurements: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 }, minTcb: { tcbEvaluationDataNumber, statuses? } }
106
+ */
107
+ export function hostPolicyProblem(policy) {
108
+ if (!policy) return "no pinned measurement is configured";
109
+ if (policy.platform !== "tdx") return `platform ${JSON.stringify(policy.platform ?? null)} is not one this client verifies (only tdx)`;
110
+ const m = policy.measurements;
111
+ const missing = MEASUREMENT_NAMES.filter((n) => !/^[0-9a-f]{96}$/.test(m?.[n] ?? ""));
112
+ if (missing.length) return `the pinned measurement does not name ${missing.join(", ")} as 96 lowercase hex characters each`;
113
+ if (!Number.isSafeInteger(policy.minTcb?.tcbEvaluationDataNumber)) return "the policy names no minimum TCB evaluation number";
114
+ return null;
115
+ }
116
+
117
+ /** The JSON body a client sends to ATTESTATION_PATH for `nonce` (32 bytes). */
118
+ export function attestationRequest(nonce) {
119
+ const n = Buffer.from(nonce);
120
+ if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
121
+ return { v: ATTESTATION_PROTOCOL_VERSION, nonce: n.toString("base64") };
122
+ }
123
+
124
+ /** Reads a host's answer. Returns { quote, collateral } or { error } in words; never throws. */
125
+ export function parseAttestationResponse(body) {
126
+ let r = body;
127
+ if (typeof r === "string" || Buffer.isBuffer(r) || r instanceof Uint8Array) {
128
+ try { r = JSON.parse(Buffer.from(r).toString("utf8")); } catch { return { error: "the response is not JSON" }; }
129
+ }
130
+ if (!r || typeof r !== "object") return { error: "the response is empty" };
131
+ if (r.v !== ATTESTATION_PROTOCOL_VERSION) return { error: `the response speaks protocol ${JSON.stringify(r.v ?? null)}, not ${ATTESTATION_PROTOCOL_VERSION}` };
132
+ if (r.platform !== "tdx") return { error: `the response is for platform ${JSON.stringify(r.platform ?? null)}, not tdx` };
133
+ if (typeof r.quote !== "string" || r.quote.length === 0) return { error: "the response carries no quote", missing: true };
134
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(r.quote)) return { error: "the quote is not base64" };
135
+ let wrapKey = null;
136
+ if (r.wrapKey != null) {
137
+ if (typeof r.wrapKey !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(r.wrapKey)) return { error: "the wrap key is not base64" };
138
+ wrapKey = Buffer.from(r.wrapKey, "base64");
139
+ if (wrapKey.length !== 32) return { error: `the wrap key is ${wrapKey.length} bytes, not 32` };
140
+ }
141
+ return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null, wrapKey };
142
+ }
143
+
144
+ // The verifier's reasons, sorted into what a person needs to know. The tests hold one case per code,
145
+ // so a reworded reason in tdx-attestation.mjs that stops matching fails there, not silently here.
146
+ function classify(reason) {
147
+ if (/^(MRTD|RTMR\d) is /.test(reason)) return REFUSAL.WRONG_MEASUREMENT;
148
+ if (/^REPORTDATA /.test(reason)) return REFUSAL.NOT_BOUND;
149
+ if (/nonce/.test(reason)) return REFUSAL.NOT_FRESH;
150
+ if (/could not be read|truncated|too short|shorter than|not understood/.test(reason)) return REFUSAL.UNREADABLE_QUOTE;
151
+ return REFUSAL.NOT_PROVEN;
152
+ }
153
+
154
+ /**
155
+ * The gate. `policy` is the client's pinned measurement (see hostPolicyProblem); `clock` returns the
156
+ * time certificates, collateral and the nonce are judged at.
157
+ *
158
+ * gate.open({ challenge }) asks the host to prove itself. `challenge(requestBody)` is the transport: it
159
+ * POSTs the body to ATTESTATION_PATH and resolves { body, tlsPublicKey }, the host's answer and the TLS
160
+ * public key of the connection that answer came over. It resolves to an attested session, or a refusal.
161
+ *
162
+ * session.send(payload, { deliver }) hands `payload` to `deliver(payload, { pin })` once. `deliver` must
163
+ * send only over a connection whose TLS key matches `pin` (httpsHostTransport does this for Node) and
164
+ * resolve { tlsPublicKey, result } naming the key it actually used; a key other than the attested one is
165
+ * reported, and a transport that refuses the wrong key throws an error with code EVDA_PIN_MISMATCH. `payload` may be a function, called only after the host is proven, so key
166
+ * material can be produced only for a host that passed.
167
+ */
168
+ export function createAttestedHostGate({ policy, nonces = createNonceBook(), clock = () => Date.now(), trust = TDX_TRUST } = {}) {
169
+ return {
170
+ async open({ challenge } = {}) {
171
+ const problem = hostPolicyProblem(policy);
172
+ if (problem) return refuse(REFUSAL.NO_POLICY, problem);
173
+ if (typeof challenge !== "function") return refuse(REFUSAL.UNREACHABLE, "no transport was given to reach the host");
174
+
175
+ const nonce = nonces.issue(clock());
176
+ let answer;
177
+ try {
178
+ answer = await challenge(attestationRequest(nonce));
179
+ } catch (err) {
180
+ return refuse(REFUSAL.UNREACHABLE, `the attestation request failed: ${err?.message ?? err}`);
181
+ }
182
+ const parsed = parseAttestationResponse(answer?.body);
183
+ if (parsed.error) return refuse(parsed.missing || answer?.body == null ? REFUSAL.NO_QUOTE : REFUSAL.UNREADABLE_QUOTE, parsed.error);
184
+ try {
185
+ parseTdxQuote(parsed.quote);
186
+ } catch (err) {
187
+ return refuse(REFUSAL.UNREADABLE_QUOTE, `the quote could not be read: ${err.message}`);
188
+ }
189
+ if (!answer.tlsPublicKey) return refuse(REFUSAL.WRONG_CONNECTION, "the transport did not report the TLS key of the connection the quote came over");
190
+
191
+ const v = verifyTdxAttestation({
192
+ quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey, wrapPublicKey: parsed.wrapKey ?? undefined,
193
+ expected: policy.measurements, minTcb: policy.minTcb, collateral: parsed.collateral, now: clock(), trust,
194
+ });
195
+ if (!v.ok) return refuse(classify(v.reason), v.reason);
196
+ return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb, wrapKey: v.wrapPublicKey ?? null });
197
+ },
198
+ };
199
+ }
200
+
201
+ function session({ pin, measurements, tcb, wrapKey }) {
202
+ return {
203
+ ok: true,
204
+ pin,
205
+ measurements,
206
+ tcb,
207
+ // The VM's key-wrapping key, only when the quote bound it; null when the host named none.
208
+ wrapKey,
209
+ async send(payload, { deliver } = {}) {
210
+ if (typeof deliver !== "function") return refuse(REFUSAL.NOT_DELIVERED, "no delivery transport was given");
211
+ let bytes;
212
+ try {
213
+ bytes = typeof payload === "function" ? await payload({ pin, measurements, wrapKey }) : payload;
214
+ } catch (err) {
215
+ return refuse(REFUSAL.NOT_DELIVERED, `the payload could not be prepared: ${err?.message ?? err}`);
216
+ }
217
+ let out;
218
+ try {
219
+ out = await deliver(bytes, { pin });
220
+ } catch (err) {
221
+ if (err?.code === "EVDA_PIN_MISMATCH") return refuse(REFUSAL.WRONG_CONNECTION, err.message);
222
+ return refuse(REFUSAL.NOT_DELIVERED, `delivery failed: ${err?.message ?? err}`);
223
+ }
224
+ // A transport that pins as it must never reaches this; one that does not has already sent, and says so.
225
+ if (!pinMatches(pin, out?.tlsPublicKey)) return { ok: false, sent: true, code: REFUSAL.WRONG_CONNECTION, reason: "the delivery went over a TLS key other than the attested one", message: `Sent over the wrong connection: ${SAID[REFUSAL.WRONG_CONNECTION]}. Treat what was sent as exposed.` };
226
+ return { ok: true, sent: true, pin, result: out.result };
227
+ },
228
+ };
229
+ }
230
+
231
+ // ─── the Node transport (the Mac client; the phone's is Swift) ──────────────────────────────────
232
+
233
+ const keyOfCert = (cert) => (cert?.raw ? new X509Certificate(cert.raw).publicKey : null);
234
+
235
+ function postJson(url, body, { pin } = {}) {
236
+ return new Promise((resolve, reject) => {
237
+ const u = new URL(url);
238
+ if (u.protocol !== "https:") return reject(new Error(`${u.origin} is not https`));
239
+ const data = Buffer.from(JSON.stringify(body));
240
+ const req = httpsRequest(u, { method: "POST", rejectUnauthorized: false, agent: false, headers: { "content-type": "application/json", "content-length": data.length } }, (res) => {
241
+ const chunks = [];
242
+ res.on("data", (c) => chunks.push(c));
243
+ res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks), tlsPublicKey }));
244
+ res.on("error", reject);
245
+ });
246
+ let tlsPublicKey = null;
247
+ req.on("error", reject);
248
+ // Nothing is written until the handshake is done and the key is read, so a pinned request to the
249
+ // wrong key leaves with no header and no body.
250
+ req.on("socket", (socket) => {
251
+ socket.once("secureConnect", () => {
252
+ try { tlsPublicKey = keyOfCert(socket.getPeerCertificate(true)); } catch { tlsPublicKey = null; }
253
+ if (pin !== undefined && !pinMatches(pin, tlsPublicKey)) {
254
+ const err = Object.assign(new Error("the server's TLS key is not the attested one"), { code: "EVDA_PIN_MISMATCH" });
255
+ req.destroy(err);
256
+ return;
257
+ }
258
+ req.end(data);
259
+ });
260
+ });
261
+ });
262
+ }
263
+
264
+ /**
265
+ * The Node transport for one hosted EVÐA at `origin` (https). challenge() for gate.open, and
266
+ * deliver(path) for session.send, which posts the payload (JSON-serializable) over a connection pinned
267
+ * to the attested key: a server presenting any other key is dropped after the handshake, before any
268
+ * header or body is written. The VM's certificate is self-signed inside the VM, so the CA check is
269
+ * replaced by the attestation and the pin, not by nothing.
270
+ */
271
+ export function httpsHostTransport(origin) {
272
+ return {
273
+ async challenge(requestBody) {
274
+ const r = await postJson(new URL(ATTESTATION_PATH, origin), requestBody);
275
+ if (r.status !== 200) throw new Error(`the host answered ${r.status}`);
276
+ return { body: r.body, tlsPublicKey: r.tlsPublicKey };
277
+ },
278
+ deliver(path) {
279
+ return async (payload, { pin }) => {
280
+ if (!pin) throw new Error("no pin was given, so delivery cannot be bound to the attested key");
281
+ const r = await postJson(new URL(path, origin), payload, { pin });
282
+ if (r.status < 200 || r.status >= 300) throw new Error(`the host answered ${r.status}`);
283
+ return { tlsPublicKey: r.tlsPublicKey, result: r.body };
284
+ };
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,147 @@
1
+ // Circle keys go to a hosted EVÐA only sealed to a key born inside the attested VM. (HQ-2083, Spec 08
2
+ // section 8: the VM holds a person's circle keys and EVÐA's operators cannot read them.)
3
+ //
4
+ // The VM side, createEnclaveKey(): the VM makes an X25519 key pair when it starts, keeps the private
5
+ // half in this closure (inside TDX-encrypted memory on a c3 VM), and puts the public half into every
6
+ // quote's REPORTDATA beside the nonce and TLS key (tdx-attestation.mjs reportDataFor, second form). It
7
+ // names the public half as `wrapKey` in its attestation answer (lib/attested-host.mjs).
8
+ //
9
+ // The client side, wrapCircleKeysTo(session, ...): `session` is what gate.open returned. Only a session
10
+ // whose quote verified under the pinned measurement AND bound the wrap key gets anything; the client's
11
+ // key holder is not asked to seal a single circle key before that. The holder is the HostCrypto shape
12
+ // every EVÐA device already has (`sealCircleKeyTo(circleId, epoch, publicKey)`, contracts/src/host.ts),
13
+ // so the raw scalar never passes through this file on a real device.
14
+ //
15
+ // The envelope is the circle key delivery wire (xania/seal/v1, the same bytes `sealCircleKeyTo` makes
16
+ // and `importCircleKey` opens, pinned by ekati's circle-seal vectors):
17
+ // [version 0x01][ephemeral X25519 public:32][ChaCha20-Poly1305 ciphertext][tag:16]
18
+ // key||nonce = HKDF-SHA256(ECDH, salt empty, info "xania/seal/v1" || ephemeral public || recipient public, 44)
19
+ // AAD = the 33-byte header.
20
+ // sealToKey and openSealed are the dependency-free copies person-record and the VM use; the test opens
21
+ // the frozen vectors with them, so a drift from the wire fails there.
22
+ //
23
+ // The payload the client delivers, version 1:
24
+ // { "v": 1, "wrapKey": "<base64>", "keys": [{ "circleId", "epoch", "sealedKey": "<base64>" }] }
25
+ //
26
+ // Not here yet: the Swift port of the client side in iris-mobile, the VM's server that holds what it
27
+ // unwrapped, and a signed key-delivery string (the relay path signs deliveries because a relay could
28
+ // plant a key; here the gate's pinned TLS key carries the payload, so the host is already the proven VM).
29
+
30
+ import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto";
31
+ import { REFUSAL, refuse } from "./attested-host.mjs";
32
+ import { reportDataFor } from "./tdx-attestation.mjs";
33
+
34
+ /** The length of a key-wrapping public key: raw X25519. */
35
+ export const WRAP_KEY_BYTES = 32;
36
+
37
+ /** The version of the wrapped-keys payload. */
38
+ export const WRAPPED_KEYS_VERSION = 1;
39
+
40
+ const SEAL_VERSION = 1;
41
+ const SEAL_KDF_DOMAIN = Buffer.from("xania/seal/v1", "utf8");
42
+ const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
43
+
44
+ const rawPublic = (keyObject) => keyObject.export({ type: "spki", format: "der" }).subarray(-32);
45
+ const x25519Public = (raw) => {
46
+ const b = Buffer.from(raw);
47
+ if (b.length !== WRAP_KEY_BYTES) throw new Error(`an X25519 public key is 32 bytes, not ${b.length}`);
48
+ return createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, b]), format: "der", type: "spki" });
49
+ };
50
+
51
+ function sealDerive(shared, ephemeralPublic, recipientPublic) {
52
+ const okm = Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), Buffer.concat([SEAL_KDF_DOMAIN, ephemeralPublic, recipientPublic]), 44));
53
+ return { key: okm.subarray(0, 32), nonce: okm.subarray(32, 44) };
54
+ }
55
+
56
+ /** Seals `plaintext` to the raw X25519 `recipientPublicKey`, with a fresh ephemeral key per call. */
57
+ export function sealToKey(recipientPublicKey, plaintext) {
58
+ const recipient = Buffer.from(recipientPublicKey);
59
+ const ephemeral = generateKeyPairSync("x25519");
60
+ const ephemeralPublic = rawPublic(ephemeral.publicKey);
61
+ const shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: x25519Public(recipient) });
62
+ const header = Buffer.concat([Buffer.from([SEAL_VERSION]), ephemeralPublic]);
63
+ const { key, nonce } = sealDerive(shared, ephemeralPublic, recipient);
64
+ const cipher = createCipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
65
+ cipher.setAAD(header, { plaintextLength: plaintext.length });
66
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]);
67
+ return Buffer.concat([header, body, cipher.getAuthTag()]);
68
+ }
69
+
70
+ /** Opens an envelope with the X25519 private KeyObject it was sealed to. Throws when it does not open. */
71
+ export function openSealed(privateKey, envelope) {
72
+ const buf = Buffer.from(envelope);
73
+ if (buf.length < 1 + 32 + 16) throw new Error(`the envelope is ${buf.length} bytes, too short to be one`);
74
+ if (buf[0] !== SEAL_VERSION) throw new Error(`unknown envelope version ${buf[0]}`);
75
+ const header = buf.subarray(0, 33);
76
+ const ephemeralPublic = buf.subarray(1, 33);
77
+ const ciphertext = buf.subarray(33, buf.length - 16);
78
+ const shared = diffieHellman({ privateKey, publicKey: x25519Public(ephemeralPublic) });
79
+ const { key, nonce } = sealDerive(shared, ephemeralPublic, rawPublic(createPublicKey(privateKey)));
80
+ const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { authTagLength: 16 });
81
+ decipher.setAAD(header, { plaintextLength: ciphertext.length });
82
+ decipher.setAuthTag(buf.subarray(buf.length - 16));
83
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
84
+ }
85
+
86
+ // ─── the VM ──────────────────────────────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * The VM's key-wrapping key, made inside the VM. Returns a frozen object holding the public half and two
90
+ * functions; the private half is reachable only through them.
91
+ * reportDataFor(nonce, tlsPublicKey): the REPORTDATA the VM asks the TDX module to sign.
92
+ * unwrapAll(payload): opens a wrapped-keys payload addressed to this key. Throws when the payload
93
+ * names another key or any envelope does not open. Returns [{ circleId, epoch, scalar }].
94
+ */
95
+ export function createEnclaveKey() {
96
+ const pair = generateKeyPairSync("x25519");
97
+ const publicKey = rawPublic(pair.publicKey);
98
+ return Object.freeze({
99
+ publicKey,
100
+ reportDataFor: (nonce, tlsPublicKey) => reportDataFor(nonce, tlsPublicKey, publicKey),
101
+ unwrapAll(payload) {
102
+ if (payload?.v !== WRAPPED_KEYS_VERSION) throw new Error(`the payload speaks version ${JSON.stringify(payload?.v ?? null)}, not ${WRAPPED_KEYS_VERSION}`);
103
+ if (!Buffer.from(String(payload.wrapKey ?? ""), "base64").equals(publicKey)) throw new Error("the payload was wrapped to another key");
104
+ if (!Array.isArray(payload.keys)) throw new Error("the payload carries no keys");
105
+ return payload.keys.map((k) => {
106
+ const scalar = openSealed(pair.privateKey, Buffer.from(String(k.sealedKey ?? ""), "base64"));
107
+ if (scalar.length !== 32) throw new Error("a circle key is 32 bytes");
108
+ return { circleId: k.circleId, epoch: k.epoch, scalar };
109
+ });
110
+ },
111
+ });
112
+ }
113
+
114
+ // ─── the client ──────────────────────────────────────────────────────────────────────────────────
115
+
116
+ function circlesProblem(circles) {
117
+ if (!Array.isArray(circles) || circles.length === 0) return "no circle keys were named";
118
+ for (const c of circles) {
119
+ if (typeof c?.circleId !== "string" || c.circleId.length === 0) return "a circle key was named without its circle";
120
+ if (!Number.isInteger(c.epoch) || c.epoch < 1) return `circle ${c.circleId} was named with epoch ${JSON.stringify(c.epoch ?? null)}, not a whole number from 1`;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Wraps the named circle keys to the attested VM's own key and delivers them once over the attested
127
+ * connection. `session` is gate.open's result; `crypto` has `sealCircleKeyTo(circleId, epoch, publicKey)`;
128
+ * `circles` is [{ circleId, epoch }]; `deliver` is the transport session.send takes.
129
+ * Returns session.send's result, or a refusal with nothing sealed and nothing sent.
130
+ */
131
+ export async function wrapCircleKeysTo(session, { crypto, circles, deliver } = {}) {
132
+ if (!session?.ok) return session?.code ? session : refuse(REFUSAL.NOT_PROVEN, "no attested session was given");
133
+ const wrapKey = session.wrapKey;
134
+ if (!wrapKey) return refuse(REFUSAL.NO_WRAP_KEY, "the host's quote bound no key-wrapping key");
135
+ if (Buffer.from(wrapKey).length !== WRAP_KEY_BYTES) return refuse(REFUSAL.NO_WRAP_KEY, "the session's wrap key is not 32 bytes");
136
+ const problem = circlesProblem(circles);
137
+ if (problem) return refuse(REFUSAL.NOTHING_TO_WRAP, problem);
138
+ if (typeof crypto?.sealCircleKeyTo !== "function") return refuse(REFUSAL.NOTHING_TO_WRAP, "no key holder was given to seal the circle keys");
139
+ return session.send(async ({ wrapKey: bound }) => {
140
+ const keys = [];
141
+ for (const { circleId, epoch } of circles) {
142
+ const sealed = await crypto.sealCircleKeyTo(circleId, epoch, new Uint8Array(bound));
143
+ keys.push({ circleId, epoch, sealedKey: Buffer.from(sealed).toString("base64") });
144
+ }
145
+ return { v: WRAPPED_KEYS_VERSION, wrapKey: Buffer.from(bound).toString("base64"), keys };
146
+ }, { deliver });
147
+ }
@@ -0,0 +1,88 @@
1
+ // Is the doctor that is about to give a verdict the doctor on origin/main? (HQ-2736)
2
+ //
3
+ // On 2026-10-19 a session checked a vault change with `evda doctor` from a checkout sitting on a
4
+ // branch merged two weeks earlier, read exit 0, and the change broke every evda PR's Argus
5
+ // baseline, because the doctor on origin/main refused what the stale one passed. The verdict was
6
+ // true of the code that ran; nothing said that code was not main's.
7
+ //
8
+ // So the doctor says, before its verdict, when its own checkout is behind origin/main or not on
9
+ // main. It says nothing on current main. It never changes the exit code: the exit is the record's
10
+ // verdict, and Argus runs the doctor from PR branches, where "not on main" is the expected state.
11
+ //
12
+ // The comparison target is origin/main after one bounded `git fetch` (FETCH_TIMEOUT_MS, no
13
+ // prompts, ssh in batch mode), because the local ref alone goes stale with the checkout: a
14
+ // checkout nobody fetched in two weeks would compare equal to its own two-week-old origin/main
15
+ // and read as current, which is the same false clean. A fetch only moves remote-tracking refs,
16
+ // never the working tree. EVDA_DOCTOR_FETCH=0 skips it and compares against the ref as last
17
+ // fetched, and says so. A failed fetch, no origin remote, or no git at all still lets every check
18
+ // run; the doctor then says it could not compare, and why.
19
+
20
+ import { spawnSync } from "node:child_process";
21
+
22
+ export const FETCH_TIMEOUT_MS = 5000;
23
+ const GIT_TIMEOUT_MS = 3000;
24
+
25
+ function git(root, args, timeout = GIT_TIMEOUT_MS, env = process.env) {
26
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout, env, stdio: ["ignore", "pipe", "pipe"] });
27
+ if (r.error) return { ok: false, out: "", err: r.error.code === "ETIMEDOUT" ? `timed out after ${timeout}ms` : String(r.error.message) };
28
+ // git's reason is its first `fatal:` line; the lines after it are advice ("and the repository exists.").
29
+ const errLines = (r.stderr ?? "").trim().split("\n").filter(Boolean);
30
+ const err = (errLines.find((l) => l.startsWith("fatal:")) ?? errLines[0] ?? "").replace(/^fatal:\s*/, "");
31
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err };
32
+ }
33
+
34
+ const short = (sha) => sha.slice(0, 7);
35
+
36
+ // Returns { state, lines }. `lines` is what the doctor prints; it is empty exactly when the
37
+ // checkout is main at origin/main as of a fetch that just succeeded.
38
+ // state: "current" | "behind" | "off-main" | "not-compared" | "not-a-checkout"
39
+ export function checkoutFreshness(root, { fetch = process.env.EVDA_DOCTOR_FETCH !== "0", fetchTimeoutMs = FETCH_TIMEOUT_MS } = {}) {
40
+ const top = git(root, ["rev-parse", "--show-toplevel"]);
41
+ if (!top.ok) {
42
+ return { state: "not-a-checkout", lines: [`could not compare this doctor against origin/main: ${root} is not a git checkout (an installed or copied doctor). Its verdict is this copy's.`] };
43
+ }
44
+ const head = git(root, ["rev-parse", "HEAD"]);
45
+ if (!head.ok) return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no commit (${head.err}).`] };
46
+ const branch = git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).out || null;
47
+ const where = branch ? `branch ${branch}` : "a detached HEAD";
48
+
49
+ if (!git(root, ["remote", "get-url", "origin"]).ok) {
50
+ return { state: "not-compared", lines: [`could not compare this doctor against origin/main: the checkout has no origin remote. It runs from ${where} at ${short(head.out)}.`] };
51
+ }
52
+
53
+ let fetchNote = null;
54
+ if (fetch) {
55
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
56
+ if (!env.GIT_SSH_COMMAND) env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=3";
57
+ const f = git(root, ["fetch", "--quiet", "--no-tags", "origin", "main"], fetchTimeoutMs, env);
58
+ if (!f.ok) fetchNote = `git fetch failed (${f.err || "no reason given"})`;
59
+ } else {
60
+ fetchNote = "fetch skipped (EVDA_DOCTOR_FETCH=0)";
61
+ }
62
+
63
+ const ref = git(root, ["rev-parse", "--verify", "-q", "refs/remotes/origin/main"]);
64
+ if (!ref.ok) {
65
+ return { state: "not-compared", lines: [`could not compare this doctor against origin/main: ${fetchNote ?? "the fetch succeeded"} and there is no origin/main ref. It runs from ${where} at ${short(head.out)}.`] };
66
+ }
67
+ const behind = Number(git(root, ["rev-list", "--count", `HEAD..${ref.out}`]).out || 0);
68
+ const ahead = Number(git(root, ["rev-list", "--count", `${ref.out}..HEAD`]).out || 0);
69
+ const onMain = branch === "main";
70
+ const against = fetchNote ? `origin/main as last fetched (${short(ref.out)}; ${fetchNote}, so main may be further ahead)` : `origin/main ${short(ref.out)}`;
71
+
72
+ const lines = [];
73
+ let state;
74
+ if (behind > 0) {
75
+ state = "behind";
76
+ lines.push(`this doctor runs from ${where} at ${short(head.out)}, ${behind} commit(s) behind ${against}. Its verdict can differ from main's; rerun from an up-to-date main before trusting it.`);
77
+ } else if (!onMain) {
78
+ state = "off-main";
79
+ lines.push(`this doctor runs from ${where} at ${short(head.out)}, not main (${ahead} commit(s) ahead of ${against}). Its verdict is this branch's, which main may not share.`);
80
+ } else {
81
+ state = "current";
82
+ }
83
+ if (fetchNote && state === "current") {
84
+ state = "not-compared";
85
+ lines.push(`could not compare this doctor against current origin/main: ${fetchNote}. It matches ${against.replace(/ \(.*$/, "")} at ${short(ref.out)}.`);
86
+ }
87
+ return { state, lines };
88
+ }
package/lib/circles.mjs CHANGED
@@ -74,6 +74,7 @@ import { resolveRole } from "./roles.mjs";
74
74
  import { boundaries, insideAny } from "./records.mjs";
75
75
  import { parseYaml, toYaml } from "./yaml.mjs";
76
76
  import { EvdaError } from "./errors.mjs";
77
+ import { outsideRecord } from "./record-walk.mjs";
77
78
 
78
79
  export const ECO_SCHEMA_VERSION = "0.1";
79
80
  // atlas-arch:yaml-encoding: the eco manifest is a config file the substrate names by its suffix, not an entity key
@@ -544,13 +545,14 @@ export function frontmatterOf(text) {
544
545
  }
545
546
  }
546
547
 
547
- // Every markdown file under the record root, skipping dot-directories and node_modules. The mark
548
+ // Every markdown file under the record root, skipping dot-directories and what outsideRecord names
549
+ // (node_modules, any *.nosync). The mark
548
550
  // walk reads the whole record, not only the record roles: a released fact may live anywhere.
549
551
  export function markdownFiles(root) {
550
552
  const out = [];
551
553
  const walk = (abs) => {
552
554
  for (const name of readdirSync(abs).sort()) {
553
- if (name.startsWith(".") || name === "node_modules") continue;
555
+ if (name.startsWith(".") || outsideRecord(name)) continue;
554
556
  const p = join(abs, name);
555
557
  // A dangling symlink (target lives only on another machine, e.g. raw media kept
556
558
  // outside the synced vault) has nothing to stat. It is not markdown content either
package/lib/clients.mjs CHANGED
@@ -40,6 +40,17 @@ export function clientsFor(command, args, { desktopConfig = null, filing = null
40
40
  text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2),
41
41
  file: desktopConfig ?? "claude_desktop_config.json",
42
42
  },
43
+ {
44
+ // Cowork starts no server from claude_desktop_config.json and reads no folder's hooks; a plugin
45
+ // is the one way in (HQ-2802, lib/plugin.mjs). The same command as the server, with plugin for mcp.
46
+ key: "cowork",
47
+ label: "Claude Cowork",
48
+ steps: [
49
+ "Cowork does not use the desktop config above. Run this once to build a plugin for this record, then in the Claude app open Customize, then Plugins, then Add, then Upload plugin, and choose the zip it names.",
50
+ ],
51
+ text: [command, ...args.map((a, i) => (i === args.length - 2 && a === "mcp" ? "plugin" : a))].map(shellQuote).join(" "),
52
+ file: null,
53
+ },
43
54
  {
44
55
  key: "project",
45
56
  label: "One folder only (.mcp.json)",
package/lib/connect.mjs CHANGED
@@ -82,7 +82,7 @@ export function guardStatus(recordDir) {
82
82
 
83
83
  // A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
84
84
  // survives an upgrade, and the versioned Cellar path process.execPath reports does not.
85
- function stableNode(execPath, env) {
85
+ export function stableNode(execPath = process.execPath, env = process.env) {
86
86
  let target;
87
87
  try { target = realpathSync(execPath); } catch { return execPath; }
88
88
  for (const d of String(env.PATH ?? "").split(delimiter)) {
@@ -0,0 +1,56 @@
1
+ // Which app is connecting, from initialize's clientInfo.name (HQ-2218). One table names each app once,
2
+ // and both uses read it: clientProfile in lib/mcp.mjs (which sections, how large a part) by the exact
3
+ // names in `names`, and chatAppOf in lib/every-chat.mjs (which every-chat setup words) by `like`.
4
+ // Before this the two were separate tables (HQ-2207, HQ-2205) that agreed only by care.
5
+ // No node: imports, because lib/every-chat.mjs reads this file and lib/clients.mjs, rendered in the
6
+ // EVDA app's webview, reads that one.
7
+ //
8
+ // names: the exact clientInfo.name values, compared trimmed and case-insensitively. claude-code and
9
+ // codex-mcp-client measured 2026-10-05 (a probe server logging clientInfo, driven by `claude -p` and
10
+ // `codex exec`). claude.ai sends Anthropic/ClaudeAI, measured 2026-10-05 in the remote server's log
11
+ // from a fresh web chat; claude-ai stays as the published form. openai-mcp is the name ChatGPT
12
+ // publishes and is unmeasured until Hestia's evda-mcp log holds one (it held none on 2026-10-15).
13
+ //
14
+ // like: a pattern for names nobody has measured yet, read top to bottom, first match wins. Every
15
+ // coding agent comes before the chat apps, because its every-session file is AGENTS.md or
16
+ // CLAUDE.md, which create-evda already writes into the record, so it gets no every-chat words
17
+ // (chat: null). A name no row matches is "other", asked with GENERIC_WHERE, so a new app still gets
18
+ // the line.
19
+ //
20
+ // surface, max, budget: what clientProfile serves an app it knows by name. max null is the smallest
21
+ // documented cap, LAYER_PART_MAX in lib/mcp.mjs; budget null is CHAT_LAYER_BUDGET there.
22
+ // Limits: claude.ai and the desktop app take a tool result of about 150,000 characters
23
+ // (claude.com/docs/connectors/building, "Design within the size and timeout limits"); Claude Code
24
+ // 25,000 tokens (same page, MAX_MCP_OUTPUT_TOKENS); OpenAI publishes no figure for ChatGPT and
25
+ // truncates an over-budget result at a line, so ChatGPT and every unknown app get the smallest
26
+ // cap. Codex documents none either and gets the same.
27
+ // claude.ai's budget is its own (HQ-2327): his chat layer measured 2026-10-07 at about 90,000
28
+ // characters whole (head 5,000, core 28,717, owner sections 56,041), and at the shared 70,000 his
29
+ // "Proactive Context Loading" (20,238, the Deep Work Queue, Weekends & Trips and friction rules)
30
+ // reached claude.ai as one listed heading, fetched 0 times in 21 layer loads. 100,000 serves it
31
+ // whole and leaves 40,000 of the 140,000 cap for what the first call adds after the layer
32
+ // (openChat: replies, recent days, people).
33
+ export const CLAUDE_AI_LAYER_BUDGET = 100_000;
34
+
35
+ export const CONNECTING_APPS = [
36
+ { app: "Claude Code", names: ["claude-code"], like: /claude[-_ ]?code/i, chat: null, surface: "terminal", max: null, budget: null },
37
+ { app: "Codex", names: ["codex-mcp-client"], like: /codex/i, chat: null, surface: "terminal", max: null, budget: null },
38
+ // Coding agents nobody has measured. No exact name, so clientProfile treats them as unknown apps.
39
+ { app: "another coding agent", names: [], like: /cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i, chat: null, surface: null, max: null, budget: null },
40
+ { app: "claude.ai", names: ["claude-ai", "anthropic/claudeai"], like: /claude|anthropic/i, chat: "claude", surface: "chat", max: 140_000, budget: CLAUDE_AI_LAYER_BUDGET },
41
+ { app: "ChatGPT", names: ["openai-mcp"], like: /openai|chatgpt/i, chat: "chatgpt", surface: "chat", max: null, budget: null },
42
+ ];
43
+
44
+ const clean = (name) => (typeof name === "string" ? name.trim() : "");
45
+
46
+ // The row whose exact names include this one, or null.
47
+ export function appNamed(name) {
48
+ const n = clean(name).toLowerCase();
49
+ return n ? CONNECTING_APPS.find((a) => a.names.includes(n)) ?? null : null;
50
+ }
51
+
52
+ // The first row whose pattern matches this name, or null.
53
+ export function appLike(name) {
54
+ const n = String(name ?? "").trim();
55
+ return CONNECTING_APPS.find((a) => a.like.test(n)) ?? null;
56
+ }