aegis-desktop 0.8.12 → 0.8.13

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.
@@ -0,0 +1,1061 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * VENDORED — do not edit. Generated by tools/vendor-fingerprint.mjs.
5
+ *
6
+ * source: aegiscodex 046ba6f — src/fingerprint.js
7
+ * rewrite: ESM -> CJS, module syntax only. No constant, purpose string,
8
+ * attested field list or digest differs from the engine's.
9
+ * verify: node --test test/fingerprint-interop.test.mjs
10
+ * refresh: node tools/vendor-fingerprint.mjs
11
+ */
12
+ /**
13
+ * fingerprint.js — φ(α), the agent fingerprint.
14
+ *
15
+ * The covenant defines the thing this file mints:
16
+ *
17
+ * Fingerprint φ(α) cryptographically verifiable identity
18
+ * carries reputation + track record + behavioural signature
19
+ *
20
+ * and then states the structural claim that makes it worth having: *identity and
21
+ * reputation are properties of the agent, not of the platform hosting it.* φ(α)
22
+ * travels. It is not a session token.
23
+ *
24
+ * So this module answers three separate questions, and keeps them separate:
25
+ *
26
+ * 1. "same credential?" `keyFingerprint(secret)` — one-way, truncated.
27
+ * 2. "same behaviour?" `behaviouralSignature(observations)` — a digest
28
+ * over what the agent actually did, order-independent
29
+ * so a re-ordered transcript is still the same agent.
30
+ * 3. "is this record intact?" `verify(record, secret)` — an HMAC attestation,
31
+ * compared in constant time.
32
+ *
33
+ * Four rules, and each is an assertion in `tests/fingerprint.test.mjs` rather
34
+ * than a sentence in a doc:
35
+ *
36
+ * 1. **A fingerprint is not an identity.** `keyFingerprint` says "same
37
+ * credential", never "same person" — two agents on one box can legitimately
38
+ * share a key. The record therefore keeps credential identity and behavioural
39
+ * signature in separate fields and `same()` requires the caller to say which
40
+ * one they mean.
41
+ * 2. **No key material escapes.** The raw secret is never a field, never a
42
+ * substring of one, and `assertNoLeak` re-checks that on every minted record:
43
+ * no field other than `attestation` may carry a long hex run. `keyFingerprint`
44
+ * is truncated to the same 12 hex the avatar registry already uses, so a
45
+ * fingerprint minted here indexes the same way a registry row does.
46
+ * 3. **A signature over nothing is not evidence.** `behaviouralSignature([])`
47
+ * returns `null`, and the record records `behaviourSamples: 0`. Absence of
48
+ * track record reads as absence, never as a zero-valued hash. The thumb axis
49
+ * follows the same rule: fewer than `touch.MIN_STROKES` strokes gives
50
+ * `touch: null`, `touchSamples: 0`.
51
+ * 4. **Verification is recomputation.** `verify` re-derives the attestation
52
+ * from the attested fields only (`ATTESTED_FIELDS`), so appending fields to
53
+ * a record cannot invalidate it and editing any attested field must.
54
+ *
55
+ * The fourth axis — `touch`, the thumb's gesture dynamics — is described in
56
+ * `src/touch.js`, including the part that matters most: it is **not** a
57
+ * fingerprint, because this hardware has no fingerprint sensor and the trackpad
58
+ * exposes no contact geometry through X. It is a behavioural biometric. Two
59
+ * consequences are structural here rather than documented:
60
+ *
61
+ * - The thumb signature is **not keyed by the secret**. A credential says "same
62
+ * key"; a habit says "same thumb". Keying one with the other would make every
63
+ * agent sharing a credential look like the same hand.
64
+ * - `mint` **recomputes** `touch` from the profile's medians via
65
+ * `touch.signatureFromMedians`, so a `signature` field sitting in a captured
66
+ * profile file is never trusted.
67
+ *
68
+ * The fifth axis is the **AI's own**: `aiKey` / `ai` / `aiSamples` / `aiModel`.
69
+ *
70
+ * Every axis above describes the *human* half of a turn — their credential, their
71
+ * demands, their thumb, their tree. Until v4 the mind on the other side of the
72
+ * turn was present only as `model`, a free-text label nobody attested, which is
73
+ * the one thing this module refuses elsewhere: a fabricated field is not
74
+ * evidence. So the record now carries both halves of a session, and they stay
75
+ * separate for the same reason credential identity and behaviour stay separate:
76
+ *
77
+ * - `aiKey` is `keyFingerprint` of the credential the session *ran on* — the
78
+ * model provider key, not the authorship key. Same derivation, same namespace,
79
+ * so `same(a, b, 'ai-credential')` answers "same mind?" and `same(a, b,
80
+ * 'credential')` still answers "same author?" without one reading as the
81
+ * other. It is `null` when the minter nominated no AI credential: an
82
+ * un-nominated key must not hash to a shared value, and the record says
83
+ * "nobody said which mind this was" rather than inventing one.
84
+ * - `ai` is a digest over the assistant's turns (which tools, which targets,
85
+ * which outcomes, which model), under its own frozen purpose string so an AI
86
+ * digest and a human-turn digest over identical text can never collide. It is
87
+ * **unkeyed**, exactly like `touch`: a habit says "same mind", not "same key".
88
+ * - `aiSamples` counts the AI turns that carried signal, and `ai` is `null` when
89
+ * none did — rule 3 again: absence of track record reads as absence.
90
+ *
91
+ * `aiModel` is the attested spelling of what `model` was only claiming: the model
92
+ * the session actually ran on, bounded and normalised like every other field.
93
+ *
94
+ * Record versions 1–3 have different attested bodies and are all kept, selected
95
+ * by `record.v`: an older record still verifies under its own field list rather
96
+ * than being silently unreadable.
97
+ *
98
+ * Pure: node:crypto only (plus the equally pure `src/touch.js`). No network, no
99
+ * spawn, no Electron — testable in plain Node, and safe to call in the
100
+ * autonomous queue where nothing is interactive.
101
+ */
102
+
103
+ const crypto = require('node:crypto');
104
+ const fs = require('node:fs');
105
+
106
+ const { signatureFromMedians, MIN_STROKES: TOUCH_MIN_STROKES } = require('./touch.js');
107
+
108
+ /** Bump when the record shape changes; `verify` refuses versions it cannot read. */
109
+ const FP_VERSION = 4;
110
+
111
+ const FP_ALGO = 'sha256';
112
+ const FP_PREFIX = 'φ_';
113
+
114
+ /**
115
+ * `sha256(secret).slice(0, 12)` — deliberately identical in shape and length to
116
+ * `desktop/lib/avatar/identity.js`'s FINGERPRINT_LENGTH, because this value is
117
+ * what that module's `keyFingerprint` rung resolves against. Short on purpose:
118
+ * it is an index for a registry row, not an authenticator.
119
+ */
120
+ const KEY_FP_LENGTH = 12;
121
+
122
+ /** Behavioural signatures are truncated too — they are labels for comparison. */
123
+ const SIG_LENGTH = 16;
124
+
125
+ /** 128-bit HMAC tag. Not the key, so the long-hex rule below exempts it. */
126
+ const ATTEST_LENGTH = 32;
127
+
128
+ /** Longest hex run tolerated in a non-attestation field before we call it a leak. */
129
+ const LEAK_HEX_RUN = 24;
130
+
131
+ const MAX_LABEL = 40;
132
+ const MAX_FIELD = 64;
133
+ const MAX_TOOLS = 64;
134
+ const MAX_OBSERVATIONS = 4096;
135
+ const MAX_DEPTH = 8;
136
+
137
+ /**
138
+ * The code axis. `CODE_LEN` is deliberately SIG_LENGTH: a 16-hex digest is
139
+ * shorter than `LEAK_HEX_RUN`, so the aggregate can never be mistaken for key
140
+ * material by `assertNoLeak`, and it is the same length as the behavioural
141
+ * signature, which is the same kind of claim.
142
+ */
143
+ const CODE_LEN = SIG_LENGTH;
144
+ const MAX_CODE_FILES = 4096;
145
+ const MAX_PATH = 200;
146
+
147
+ /** v1 attested body — no thumb axis. Kept so v1 records still verify. */
148
+ const ATTESTED_FIELDS_V1 = Object.freeze([
149
+ 'v',
150
+ 'algo',
151
+ 'keyFingerprint',
152
+ 'label',
153
+ 'account',
154
+ 'model',
155
+ 'tools',
156
+ 'behaviour',
157
+ 'behaviourSamples',
158
+ 'createdAt',
159
+ ]);
160
+
161
+ /**
162
+ * The purpose prefix of the behavioural digest is FROZEN and must not track
163
+ * `FP_VERSION`.
164
+ *
165
+ * It used to be `aegiscode-behaviour-v${FP_VERSION}`, which quietly coupled two
166
+ * unrelated things: bumping the *record shape* changed the *behavioural axis*.
167
+ * Same observations, new version, different signature — so a v2 record's
168
+ * `behaviour` could no longer be reproduced by re-running the tool that minted
169
+ * it, which is precisely what makes the axis comparable across time. The
170
+ * algorithm has not changed since v2, so the string is pinned there.
171
+ */
172
+ const BEHAVIOUR_PURPOSE = 'aegiscode-behaviour-v2';
173
+
174
+ /**
175
+ * The AI axis's purpose prefix, frozen for the same reason and separately from
176
+ * `BEHAVIOUR_PURPOSE`.
177
+ *
178
+ * Domain separation is the whole point of the string here: the human's turns and
179
+ * the assistant's turns are hashed from the same observation primer, so without a
180
+ * distinct prefix a record whose two axes happened to see the same text would
181
+ * carry one digest under two names — and "same author" would silently read as
182
+ * "same mind". Pinned to v1 and not to `FP_VERSION`: the algorithm must not
183
+ * change because the record shape did.
184
+ */
185
+ const AI_PURPOSE = 'aegiscode-ai-v1';
186
+
187
+ /** v2 attested body — adds the thumb axis. Field order is load-bearing and frozen. */
188
+ const ATTESTED_FIELDS_V2 = Object.freeze([
189
+ 'v',
190
+ 'algo',
191
+ 'keyFingerprint',
192
+ 'label',
193
+ 'account',
194
+ 'model',
195
+ 'tools',
196
+ 'behaviour',
197
+ 'behaviourSamples',
198
+ 'touch',
199
+ 'touchSamples',
200
+ 'createdAt',
201
+ ]);
202
+
203
+ /**
204
+ * v3 attested body — adds the code axis: *which revision of which files produced
205
+ * this turn*. Rule 4 still holds — appending a field to a record must not
206
+ * invalidate it — so a v1 or v2 record keeps verifying under its own list.
207
+ */
208
+ const ATTESTED_FIELDS_V3 = Object.freeze([
209
+ 'v',
210
+ 'algo',
211
+ 'keyFingerprint',
212
+ 'label',
213
+ 'account',
214
+ 'model',
215
+ 'tools',
216
+ 'behaviour',
217
+ 'behaviourSamples',
218
+ 'touch',
219
+ 'touchSamples',
220
+ 'code',
221
+ 'codeFiles',
222
+ 'codeDirty',
223
+ 'createdAt',
224
+ ]);
225
+
226
+ /**
227
+ * v4 attested body — adds the AI axis: *which mind answered, and what it did*,
228
+ * beside the human half the earlier versions already carried.
229
+ *
230
+ * The AI fields sit before `createdAt` and after the human's, so the two halves
231
+ * read in the order a session happens (the demand, then the answer). Field order
232
+ * is load-bearing and frozen: this list is the attestation preimage, so
233
+ * reordering it would invalidate every v4 record ever minted.
234
+ */
235
+ const ATTESTED_FIELDS_V4 = Object.freeze([
236
+ 'v',
237
+ 'algo',
238
+ 'keyFingerprint',
239
+ 'label',
240
+ 'account',
241
+ 'model',
242
+ 'tools',
243
+ 'behaviour',
244
+ 'behaviourSamples',
245
+ 'touch',
246
+ 'touchSamples',
247
+ 'code',
248
+ 'codeFiles',
249
+ 'codeDirty',
250
+ 'aiKey',
251
+ 'aiModel',
252
+ 'ai',
253
+ 'aiSamples',
254
+ 'createdAt',
255
+ ]);
256
+
257
+ /** The fields the current version attests. Exported for callers that print them. */
258
+ const ATTESTED_FIELDS = ATTESTED_FIELDS_V4;
259
+
260
+ const ATTESTED_FIELDS_BY_VERSION = Object.freeze({
261
+ 1: ATTESTED_FIELDS_V1,
262
+ 2: ATTESTED_FIELDS_V2,
263
+ 3: ATTESTED_FIELDS_V3,
264
+ 4: ATTESTED_FIELDS_V4,
265
+ });
266
+
267
+ /** null for a version this build cannot read — verification refuses rather than guesses. */
268
+ function attestedFieldsFor(version) {
269
+ // `v` comes from untrusted JSON on the verify path. Two coercions make a
270
+ // bare property read wrong here: numeric keys are stored as strings, so
271
+ // ATTESTED_FIELDS_BY_VERSION['2'] resolves and a record carrying the *string*
272
+ // "2" would pin its own field list; and an inherited key ('constructor',
273
+ // '__proto__') resolves to a function or object rather than a field list.
274
+ // Require a real integer and an own property.
275
+ if (typeof version !== 'number' || !Number.isInteger(version)) return null;
276
+ return Object.prototype.hasOwnProperty.call(ATTESTED_FIELDS_BY_VERSION, version)
277
+ ? ATTESTED_FIELDS_BY_VERSION[version]
278
+ : null;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // canonicalisation
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Collapse a value to its comparable form: trimmed, whitespace-collapsed,
287
+ * lowercased. Hashing is the only consumer, so display casing is preserved only
288
+ * in `label`.
289
+ */
290
+ function normalise(value) {
291
+ if (value === null || value === undefined) return '';
292
+ return String(value).trim().replace(/\s+/g, ' ').toLowerCase();
293
+ }
294
+
295
+ /**
296
+ * Deterministic JSON: keys sorted, undefined/function/symbol dropped, numbers
297
+ * non-finite -> null, strings normalised, recursion bounded. Two structurally
298
+ * equal records always produce byte-identical output, which is what lets
299
+ * `verify` be a recomputation rather than a stored comparison.
300
+ */
301
+ function canonical(value, depth = 0) {
302
+ if (depth > MAX_DEPTH) return '"[depth]"';
303
+ if (value === null || value === undefined) return 'null';
304
+
305
+ const t = typeof value;
306
+ if (t === 'number') return Number.isFinite(value) ? String(value) : 'null';
307
+ if (t === 'boolean') return value ? 'true' : 'false';
308
+ if (t === 'string') return JSON.stringify(normalise(value));
309
+ if (t === 'function' || t === 'symbol' || t === 'bigint') return 'null';
310
+
311
+ if (Array.isArray(value)) {
312
+ return '[' + value.map((v) => canonical(v, depth + 1)).join(',') + ']';
313
+ }
314
+
315
+ if (t === 'object') {
316
+ const keys = Object.keys(value)
317
+ .filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
318
+ .sort();
319
+ return (
320
+ '{' +
321
+ keys.map((k) => JSON.stringify(k) + ':' + canonical(value[k], depth + 1)).join(',') +
322
+ '}'
323
+ );
324
+ }
325
+
326
+ return 'null';
327
+ }
328
+
329
+ /** Purpose-prefixed digest, hex. The prefix keeps one purpose's digest from ever
330
+ * colliding with another's over the same input. */
331
+ function digest(purpose, body) {
332
+ return crypto.createHash(FP_ALGO).update(purpose + '\n' + body, 'utf8').digest('hex');
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // redaction helpers
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * A label safe to store and display: bounded length, and control characters /
341
+ * newlines stripped so a value can never forge a second record line. Mirrors
342
+ * `avatar/identity.js`'s `safeLabel`, including its rule that a rejected value
343
+ * is never echoed back.
344
+ */
345
+ function safeLabel(value, max = MAX_LABEL) {
346
+ if (value === null || value === undefined) return '';
347
+ const cleaned = String(value)
348
+ // eslint-disable-next-line no-control-regex
349
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
350
+ .replace(/\s+/g, ' ')
351
+ .trim();
352
+ if (!cleaned) return '';
353
+ return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
354
+ }
355
+
356
+ /** Any run of >= `n` hex characters. Keys, digests and seeds all look like this. */
357
+ function longHexRun(value, n = LEAK_HEX_RUN) {
358
+ return new RegExp(`[0-9a-fA-F]{${n},}`).test(String(value));
359
+ }
360
+
361
+ const HEX_ATTESTATION = new RegExp(`^[0-9a-f]{${ATTEST_LENGTH}}$`);
362
+
363
+ function isFingerprintShape(value) {
364
+ return new RegExp(`^[0-9a-f]{${KEY_FP_LENGTH}}$`).test(String(value || ''));
365
+ }
366
+
367
+ /**
368
+ * Refuse to return a record that carries the secret or anything that looks like
369
+ * a key. Throws rather than redacting: a caller that reached this point has a
370
+ * bug in the shape of what it passes in, and silently dropping the field would
371
+ * hide it.
372
+ */
373
+ function assertNoLeak(record, secret, alsoSecret) {
374
+ const secrets = [secret, alsoSecret]
375
+ .map((v) => (v === null || v === undefined ? '' : String(v)))
376
+ .filter(Boolean);
377
+ for (const [key, value] of Object.entries(record)) {
378
+ if (value === null || value === undefined) continue;
379
+ const text = Array.isArray(value) ? value.join('\u0000') : String(value);
380
+
381
+ for (const s of secrets) {
382
+ if (text.includes(s)) {
383
+ throw new Error(`fingerprint: field "${key}" would carry the secret verbatim`);
384
+ }
385
+ }
386
+ if (key === 'attestation') {
387
+ if (!HEX_ATTESTATION.test(text)) {
388
+ throw new Error('fingerprint: attestation must be 32 lowercase hex characters');
389
+ }
390
+ continue;
391
+ }
392
+ if (longHexRun(text)) {
393
+ throw new Error(`fingerprint: field "${key}" carries a long hex run (possible key leak)`);
394
+ }
395
+ }
396
+ return true;
397
+ }
398
+
399
+ /**
400
+ * The same rule, applied to the *raw* inputs before any bounding.
401
+ *
402
+ * Without this, truncation is a laundering route: `label: 'x'.repeat(12) + secret`
403
+ * gets cut to MAX_LABEL, the secret no longer appears whole, and twenty hex
404
+ * characters of a live key land in the record unremarked. A caller that hands a
405
+ * credential to a display field has a bug, and rule 2 says a caller bug in this
406
+ * shape becomes loud rather than quiet — which is exactly what `assertNoLeak`
407
+ * cannot do once the value has already been trimmed to fit.
408
+ */
409
+ function assertInputsClean(inputs, secret, alsoSecret) {
410
+ const secrets = [secret, alsoSecret]
411
+ .map((v) => (v === null || v === undefined ? '' : String(v)))
412
+ .filter(Boolean);
413
+ if (!secrets.length) return true;
414
+ for (const field of ['label', 'account', 'model', 'tools', 'aiModel']) {
415
+ const value = inputs[field];
416
+ if (value === null || value === undefined) continue;
417
+ const text = Array.isArray(value) ? value.join('\u0000') : String(value);
418
+ if (secrets.some((s) => text.includes(s))) {
419
+ throw new Error(`fingerprint: input "${field}" would carry the secret verbatim`);
420
+ }
421
+ if (longHexRun(text)) {
422
+ throw new Error(`fingerprint: input "${field}" carries a long hex run (possible key leak)`);
423
+ }
424
+ }
425
+ return true;
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // 1. credential identity
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /**
433
+ * `sha256('aegiscode-agent-v1:' + secret).slice(0, 12)`, hex — or `null` when no
434
+ * secret is available, because "no credential" must not hash to a shared value
435
+ * that would make every keyless agent look like the same agent.
436
+ *
437
+ * @param {string} secret - API key, device fingerprint seed, or any stable credential.
438
+ * @returns {string|null}
439
+ */
440
+ function keyFingerprint(secret) {
441
+ if (secret === null || secret === undefined) return null;
442
+ const s = String(secret).trim();
443
+ if (!s) return null;
444
+ return digest('aegiscode-agent-v1', s).slice(0, KEY_FP_LENGTH);
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // 2. behavioural signature
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /** One observation rendered canonically, or '' if it carries nothing usable. */
452
+ function observationKey(observation) {
453
+ if (typeof observation === 'string' || typeof observation === 'number') {
454
+ return normalise(observation);
455
+ }
456
+ if (!observation || typeof observation !== 'object') return '';
457
+ const parts = [observation.tool, observation.target, observation.outcome, observation.model]
458
+ .map((p) => normalise(p))
459
+ .join('|');
460
+ return parts.replace(/^\|+|\|+$/g, '') ? parts : '';
461
+ }
462
+
463
+ /** How many observations actually carried signal. */
464
+ function countObservations(observations) {
465
+ if (!Array.isArray(observations)) return 0;
466
+ return observations.slice(0, MAX_OBSERVATIONS).filter((o) => observationKey(o) !== '').length;
467
+ }
468
+
469
+ /**
470
+ * A digest over a set of observations — deduplicated and sorted, so the same set
471
+ * of actions fingerprints identically however the transcript was ordered, and a
472
+ * replayed action adds nothing. This is the "behavioural signature" of the
473
+ * covenant, not a transcript hash: two agents that used the same tools the same
474
+ * way match, and a padded log does not inflate the match.
475
+ *
476
+ * The purpose string is a parameter because this primer serves two axes that must
477
+ * not be able to agree by accident — the human's turns and the assistant's (see
478
+ * `AI_PURPOSE`). Callers outside this file get `behaviouralSignature` and
479
+ * `aiSignature`, each with its purpose bound.
480
+ *
481
+ * @returns {string|null} null when there is no observable behaviour — see rule 3.
482
+ */
483
+ function signatureFromObservations(observations, purpose) {
484
+ if (!Array.isArray(observations)) return null;
485
+ const keys = observations
486
+ .slice(0, MAX_OBSERVATIONS)
487
+ .map(observationKey)
488
+ .filter(Boolean);
489
+ if (!keys.length) return null;
490
+ const unique = Array.from(new Set(keys)).sort();
491
+ return digest(purpose, unique.join('\n')).slice(0, SIG_LENGTH);
492
+ }
493
+
494
+ function behaviouralSignature(observations) {
495
+ return signatureFromObservations(observations, BEHAVIOUR_PURPOSE);
496
+ }
497
+
498
+ /**
499
+ * The AI axis's behavioural signature: the same primer over the assistant's own
500
+ * turns, under `AI_PURPOSE`.
501
+ *
502
+ * `null` for no observations, for the same reason `behaviouralSignature` is: a
503
+ * mind with no recorded track record has no track record, and rendering that as a
504
+ * zero-valued hash would be a claim the evidence does not support.
505
+ */
506
+ function aiSignature(observations) {
507
+ return signatureFromObservations(observations, AI_PURPOSE);
508
+ }
509
+
510
+ /** Bounded, deduplicated tool list in canonical order. */
511
+ function normaliseTools(tools) {
512
+ if (!Array.isArray(tools)) return Object.freeze([]);
513
+ const seen = new Set();
514
+ for (const t of tools.slice(0, MAX_TOOLS)) {
515
+ const n = normalise(typeof t === 'string' ? t : (t && (t.name || t.id)) || '');
516
+ if (n) seen.add(n);
517
+ }
518
+ return Object.freeze(Array.from(seen).sort());
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // 2b. the code axis — which revision of which files produced this turn
523
+ // ---------------------------------------------------------------------------
524
+
525
+ /**
526
+ * A repo-relative path in comparable form, or `null` if unusable.
527
+ *
528
+ * Separators are normalised to `/` and a leading `./` is dropped so the same
529
+ * file reached two ways hashes once. Absolute paths and any path containing a
530
+ * `..` segment are refused rather than "cleaned": a record whose paths could
531
+ * escape its own root would name files outside the tree it claims to describe,
532
+ * and the whole point of the axis is that the claim is recomputable.
533
+ */
534
+ function normaliseCodePath(value) {
535
+ if (value === null || value === undefined) return null;
536
+ const raw = String(value).replace(/\\/g, '/').trim();
537
+ if (!raw) return null;
538
+ if (raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw)) return null;
539
+ const parts = [];
540
+ for (const seg of raw.split('/')) {
541
+ if (seg === '' || seg === '.') continue;
542
+ if (seg === '..') return null;
543
+ parts.push(seg);
544
+ }
545
+ const rel = parts.join('/');
546
+ if (!rel) return null;
547
+ return rel.length > MAX_PATH ? null : rel;
548
+ }
549
+
550
+ /**
551
+ * One file's contribution: `sha256('aegiscode-code-v3' + '\0' + relpath + '\0' +
552
+ * content)`.
553
+ *
554
+ * The path is inside the hash on purpose. Hashing contents alone would make a
555
+ * rename invisible, and a tree where `auth.js` and `handler.js` swap names is
556
+ * not the same tree. Length-prefixing nothing and delimiting with NUL keeps
557
+ * path and content from bleeding into each other: `('ab', 'c')` and `('a',
558
+ * 'bc')` cannot collide.
559
+ */
560
+ function codeFileDigest(relpath, content) {
561
+ const rel = normaliseCodePath(relpath);
562
+ if (!rel) throw new Error(`fingerprint: unusable code path ${JSON.stringify(String(relpath))}`);
563
+ const buf = Buffer.isBuffer(content)
564
+ ? content
565
+ : Buffer.from(content === null || content === undefined ? '' : String(content), 'utf8');
566
+ return crypto
567
+ .createHash(FP_ALGO)
568
+ .update(`aegiscode-code-v3\u0000${rel}\u0000`, 'utf8')
569
+ .update(buf)
570
+ .digest('hex');
571
+ }
572
+
573
+ /**
574
+ * Fold per-file digests, in sorted path order, into one 16-hex aggregate.
575
+ *
576
+ * Order-independence comes from the sort, not from the caller: a directory walk
577
+ * returns files in whatever order the filesystem feels like, and two walks of
578
+ * one tree must agree. The fold chains each file's digest into the running
579
+ * accumulator, so moving a byte from one file to another changes the result —
580
+ * unlike summing or XOR-ing per-file digests, which would not.
581
+ *
582
+ * @param {Array<{path: string, content: string|Buffer}>} files
583
+ * @returns {{code: string|null, codeFiles: number}} `null`/`0` for no files —
584
+ * absence of code evidence reads as absence (rule 3), never as a
585
+ * zero-valued hash that a caller might mistake for a real revision.
586
+ */
587
+ function codeDigest(files) {
588
+ if (!Array.isArray(files)) return { code: null, codeFiles: 0 };
589
+
590
+ // Sorted input, duplicates resolved last-wins: a path listed twice is the same
591
+ // file twice, and folding both would make the aggregate depend on how many
592
+ // times the caller mentioned it.
593
+ const byPath = new Map();
594
+ for (const f of files) {
595
+ if (!f || typeof f !== 'object') continue;
596
+ const rel = normaliseCodePath(f.path);
597
+ if (!rel) continue;
598
+ byPath.set(rel, { path: rel, content: f.content });
599
+ }
600
+ const entries = Array.from(byPath.keys()).sort();
601
+ if (!entries.length) return { code: null, codeFiles: 0 };
602
+ if (entries.length > MAX_CODE_FILES) {
603
+ throw new Error(
604
+ `fingerprint: ${entries.length} code files exceeds MAX_CODE_FILES (${MAX_CODE_FILES}); ` +
605
+ 'hash a subtree rather than silently attesting a prefix of one',
606
+ );
607
+ }
608
+
609
+ let acc = digest('aegiscode-code-v3-root', String(entries.length));
610
+ for (const rel of entries) {
611
+ const d = codeFileDigest(rel, byPath.get(rel).content);
612
+ acc = digest('aegiscode-code-v3-fold', `${acc}\n${rel}\n${d}`);
613
+ }
614
+ return { code: acc.slice(0, CODE_LEN), codeFiles: entries.length };
615
+ }
616
+
617
+ /**
618
+ * The code axis of a record, with dirtiness passed through honestly.
619
+ *
620
+ * `codeDirty` is `true`/`false` only when the caller actually knew; anything
621
+ * else is `null`. The CLI cannot tell clean from dirty without spawning `git`,
622
+ * and this module's callers are promised a module that spawns nothing — so
623
+ * "unknown" is a state the record is allowed to be in, and it is stored as
624
+ * unknown rather than being rendered as `false`, which would be a claim the
625
+ * evidence does not support.
626
+ */
627
+ function codeAxisFrom(files, codeDirty) {
628
+ const { code, codeFiles } = codeDigest(files);
629
+ const dirty = typeof codeDirty === 'boolean' ? codeDirty : null;
630
+ return { code, codeFiles, codeDirty: code ? dirty : null };
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // 2c. the AI axis — which mind answered, and what it did
635
+ // ---------------------------------------------------------------------------
636
+
637
+ /**
638
+ * Fold the AI half of a record out of the caller's inputs.
639
+ *
640
+ * Every field degrades to `null`/`0` rather than to a stand-in value, and for the
641
+ * same reason the other axes do: a record may carry only what was observed.
642
+ *
643
+ * - no nominated credential ⇒ `aiKey: null`. NOT a hash of the authorship
644
+ * key: the authorship key says who *sealed* the record, and reusing it here
645
+ * would make every record look like it ran under its author's own key, which
646
+ * is a claim about the session that nobody made.
647
+ * - no AI observations ⇒ `ai: null, aiSamples: 0` (rule 3).
648
+ * - no model named ⇒ `aiModel: null`, never the empty string.
649
+ *
650
+ * @param {object} [axis]
651
+ * @param {string} [axis.secret] - the credential the session ran on. Never stored.
652
+ * @param {string} [axis.model] - the model that answered.
653
+ * @param {Array} [axis.observations]- the assistant's turns: `{tool, target, outcome, model}`.
654
+ * @returns {{aiKey: string|null, aiModel: string|null, ai: string|null, aiSamples: number}}
655
+ */
656
+ function aiAxisFrom(axis = {}) {
657
+ const { secret, model, observations } = axis || {};
658
+ const aiKey = keyFingerprint(secret);
659
+ const ai = aiSignature(observations);
660
+ return {
661
+ aiKey,
662
+ aiModel: safeLabel(model, MAX_FIELD) || null,
663
+ ai,
664
+ aiSamples: ai ? countObservations(observations) : 0,
665
+ };
666
+ }
667
+
668
+ // ---------------------------------------------------------------------------
669
+ // 3. mint / attest / verify
670
+ // ---------------------------------------------------------------------------
671
+
672
+ /**
673
+ * The exact bytes the attestation covers. The field list is chosen by the
674
+ * record's own `v`, so a v1 record is attested over v1's fields and keeps
675
+ * verifying after the shape moved on. Unknown fields are ignored either way.
676
+ */
677
+ function attestationBody(record) {
678
+ const fields = attestedFieldsFor(record && record.v);
679
+ if (!fields) {
680
+ throw new Error(`fingerprint: cannot attest version ${record && record.v}`);
681
+ }
682
+ const body = {};
683
+ for (const field of fields) {
684
+ const v = record[field];
685
+ body[field] = v === undefined ? null : v;
686
+ }
687
+ return canonical(body);
688
+ }
689
+
690
+ /** HMAC-SHA256(secret, attested fields), truncated to 128 bits. */
691
+ function attest(record, secret) {
692
+ if (!secret) throw new Error('fingerprint: a secret is required to attest a record');
693
+ return crypto
694
+ .createHmac(FP_ALGO, String(secret))
695
+ .update(attestationBody(record), 'utf8')
696
+ .digest('hex')
697
+ .slice(0, ATTEST_LENGTH);
698
+ }
699
+
700
+ function isoTs(value) {
701
+ if (value === null || value === undefined) return new Date().toISOString();
702
+ const d = value instanceof Date ? value : new Date(value);
703
+ if (Number.isNaN(d.getTime())) throw new Error('fingerprint: createdAt is not a valid date');
704
+ return d.toISOString();
705
+ }
706
+
707
+ /**
708
+ * The thumb axis of a record, recomputed from a captured profile's medians.
709
+ *
710
+ * Deliberately ignores any `signature` field the profile carries: recomputation
711
+ * is what makes the record's thumb axis trustworthy, and trusting the file's own
712
+ * hash would make it the one field in φ(α) that was taken on faith. Unusable
713
+ * input degrades to "no thumb axis" (`null`/`0`), never to a zero-valued hash and
714
+ * never to a leaked string.
715
+ *
716
+ * @param {object} profile - shape as produced by `src/touch.js`'s `profile()`.
717
+ * @returns {{signature: string|null, samples: number}}
718
+ */
719
+ function touchSignatureFrom(profile) {
720
+ if (!profile || typeof profile !== 'object') return { signature: null, samples: 0 };
721
+ const samples = Number(profile.samples) || 0;
722
+ const signature = signatureFromMedians(profile.medians, samples);
723
+ return { signature, samples: signature ? samples : 0 };
724
+ }
725
+
726
+ /**
727
+ * Mint φ(α).
728
+ *
729
+ * @param {object} input
730
+ * @param {string} input.secret - credential; required. Never stored.
731
+ * @param {string} [input.label] - human display name.
732
+ * @param {string} [input.account] - cloud account id (survives key rotation).
733
+ * @param {string} [input.model] - model the agent runs on.
734
+ * @param {string[]} [input.tools] - tools it can reach.
735
+ * @param {Array} [input.observations] - what it did; feeds the behavioural signature.
736
+ * @param {object} [input.touch] - thumb profile from `src/touch.js`; feeds the
737
+ * gesture-dynamics signature. NOT keyed by the
738
+ * secret: a credential is not a hand.
739
+ * @param {Array<{path: string, content: string|Buffer}>} [input.code]
740
+ * - the revision this turn ran against, as
741
+ * repo-relative path + content pairs. The
742
+ * walk that produces them lives in the caller
743
+ * (a tool), so this module stays I/O-free
744
+ * apart from test vectors.
745
+ * @param {boolean} [input.codeDirty] - whether that tree had uncommitted changes,
746
+ * when the caller could actually tell.
747
+ * @param {string|Date} [input.createdAt]
748
+ * @returns {object} frozen, attested record.
749
+ */
750
+ function mint(input = {}) {
751
+ const {
752
+ secret,
753
+ label,
754
+ account,
755
+ model,
756
+ tools,
757
+ observations,
758
+ touch,
759
+ code,
760
+ codeDirty,
761
+ aiSecret,
762
+ aiModel,
763
+ aiObservations,
764
+ createdAt,
765
+ } = input;
766
+ const kf = keyFingerprint(secret);
767
+ if (!kf) {
768
+ throw new Error('fingerprint: a secret (API key or device seed) is required to mint φ(α)');
769
+ }
770
+
771
+ // Before bounding: a key in a display field must refuse, not get truncated.
772
+ // Both credentials are checked — the AI's key is a live provider key, and a
773
+ // record carrying it in `aiModel` would be exactly the leak this guard exists
774
+ // to catch.
775
+ assertInputsClean({ label, account, model, tools, aiModel }, secret, aiSecret);
776
+
777
+ const thumb = touchSignatureFrom(touch);
778
+ const codeAxis = codeAxisFrom(code, codeDirty);
779
+ const ai = aiAxisFrom({ secret: aiSecret, model: aiModel, observations: aiObservations });
780
+
781
+ const record = {
782
+ v: FP_VERSION,
783
+ algo: FP_ALGO,
784
+ keyFingerprint: kf,
785
+ label: safeLabel(label, MAX_LABEL),
786
+ account: safeLabel(account, MAX_FIELD) || null,
787
+ model: safeLabel(model, MAX_FIELD) || null,
788
+ tools: normaliseTools(tools),
789
+ behaviour: behaviouralSignature(observations),
790
+ behaviourSamples: countObservations(observations),
791
+ touch: thumb.signature,
792
+ touchSamples: thumb.samples,
793
+ code: codeAxis.code,
794
+ codeFiles: codeAxis.codeFiles,
795
+ codeDirty: codeAxis.codeDirty,
796
+ aiKey: ai.aiKey,
797
+ aiModel: ai.aiModel,
798
+ ai: ai.ai,
799
+ aiSamples: ai.aiSamples,
800
+ createdAt: isoTs(createdAt),
801
+ };
802
+
803
+ const sealed = { ...record, attestation: attest(record, secret) };
804
+ assertNoLeak(sealed, secret, aiSecret);
805
+ return Object.freeze(sealed);
806
+ }
807
+
808
+ /** `φ_<keyFingerprint><behaviour|——>`: short, stable, safe to log. */
809
+ function id(record) {
810
+ if (!record || !isFingerprintShape(record.keyFingerprint)) {
811
+ throw new Error('fingerprint: not a fingerprinted record');
812
+ }
813
+ return `${FP_PREFIX}${record.keyFingerprint}-${record.behaviour || 'unobserved'}`;
814
+ }
815
+
816
+ function sameReason(record) {
817
+ if (!record || typeof record !== 'object') return 'not a record';
818
+ if (!attestedFieldsFor(record.v)) return `unsupported version ${record.v}`;
819
+ if (record.algo !== FP_ALGO) return `unsupported algo ${record.algo}`;
820
+ if (!isFingerprintShape(record.keyFingerprint)) return 'keyFingerprint is malformed';
821
+ return null;
822
+ }
823
+
824
+ /**
825
+ * Verify a record by recomputation, not by trusting its stored tag.
826
+ *
827
+ * @returns {{ok: boolean, reason: string|null, id: string|null, credentialMatch: boolean|null}}
828
+ */
829
+ function verify(record, secret) {
830
+ const bad = sameReason(record);
831
+ if (bad) return { ok: false, reason: bad, id: null, credentialMatch: null };
832
+
833
+ const credentialMatch = secret ? keyFingerprint(secret) === record.keyFingerprint : null;
834
+
835
+ if (!secret) {
836
+ return {
837
+ ok: false,
838
+ reason: 'no secret supplied — attestation cannot be recomputed',
839
+ id: id(record),
840
+ credentialMatch: null,
841
+ };
842
+ }
843
+
844
+ const expected = attest(record, secret);
845
+ const got = String(record.attestation || '');
846
+ const a = Buffer.from(expected, 'utf8');
847
+ const b = Buffer.from(got.padEnd(expected.length, ' ').slice(0, expected.length), 'utf8');
848
+ const tagOk = a.length === b.length && crypto.timingSafeEqual(a, b);
849
+
850
+ if (!tagOk) {
851
+ return { ok: false, reason: 'attestation mismatch', id: id(record), credentialMatch };
852
+ }
853
+ if (!credentialMatch) {
854
+ return {
855
+ ok: false,
856
+ reason: 'attestation valid but secret does not match keyFingerprint',
857
+ id: id(record),
858
+ credentialMatch: false,
859
+ };
860
+ }
861
+ return { ok: true, reason: null, id: id(record), credentialMatch: true };
862
+ }
863
+
864
+ /**
865
+ * Compare two records on one axis. Explicit on purpose: "same credential" and
866
+ * "same behaviour" are different claims (rule 1), and a caller that does not say
867
+ * which one it wants gets an error rather than an answer.
868
+ *
869
+ * @param {'credential'|'behaviour'|'full'} axis
870
+ */
871
+ function same(a, b, axis = 'full') {
872
+ if (!a || !b) return false;
873
+ if (axis === 'credential') return a.keyFingerprint === b.keyFingerprint;
874
+ if (axis === 'behaviour') {
875
+ return Boolean(a.behaviour) && a.behaviour === b.behaviour;
876
+ }
877
+ if (axis === 'full') {
878
+ return a.keyFingerprint === b.keyFingerprint && a.behaviour === b.behaviour;
879
+ }
880
+ if (axis === 'ai-credential') {
881
+ return Boolean(a.aiKey) && a.aiKey === b.aiKey;
882
+ }
883
+ if (axis === 'ai') {
884
+ return Boolean(a.ai) && a.ai === b.ai;
885
+ }
886
+ if (axis === 'mind') {
887
+ return (
888
+ Boolean(a.aiKey) && a.aiKey === b.aiKey && Boolean(a.ai) && a.ai === b.ai
889
+ );
890
+ }
891
+ throw new Error(`fingerprint: unknown comparison axis "${axis}"`);
892
+ }
893
+
894
+ /** Display form. Carries no field a key could hide in. */
895
+ function describe(record) {
896
+ const bad = sameReason(record);
897
+ if (bad) return `φ(?) ${bad}`;
898
+ const bits = [
899
+ `key ${record.keyFingerprint}`,
900
+ record.behaviour ? `behaviour ${record.behaviour}` : 'behaviour unobserved',
901
+ record.behaviourSamples ? `${record.behaviourSamples} samples` : null,
902
+ record.touch ? `touch ${record.touch}` : 'touch unobserved',
903
+ record.touchSamples ? `${record.touchSamples} strokes` : null,
904
+ record.code
905
+ ? `code ${record.code}${record.codeDirty === null ? '' : record.codeDirty ? ' (dirty)' : ' (clean)'}`
906
+ : 'code unobserved',
907
+ record.codeFiles ? `${record.codeFiles} files` : null,
908
+ record.ai && record.aiKey
909
+ ? `ai ${record.ai} on ${record.aiKey}`
910
+ : record.ai
911
+ ? `ai ${record.ai} (no key nominated)`
912
+ : record.aiKey
913
+ ? `ai key ${record.aiKey}, behaviour unobserved`
914
+ : 'ai unobserved',
915
+ record.aiSamples ? `${record.aiSamples} ai turn(s)` : null,
916
+ record.aiModel ? `ran on ${record.aiModel}` : null,
917
+ record.model ? `model ${record.model}` : null,
918
+ record.tools && record.tools.length ? `tools ${record.tools.length}` : null,
919
+ ].filter(Boolean);
920
+ return `${record.label || 'agent'} · ${bits.join(' · ')}`;
921
+ }
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // CLI — `node src/fingerprint.js --label x --observations file.json`
925
+ // `node src/fingerprint.js --ai-observations ai.json --ai-model gpt-5`
926
+ // `node src/fingerprint.js --verify record.json`
927
+ //
928
+ // The secret comes from AEGIS_FINGERPRINT_SECRET or --secret-file, never from
929
+ // argv: argv is visible in `ps` to every process on the box.
930
+ // ---------------------------------------------------------------------------
931
+
932
+ /**
933
+ * The AI half's credential, from the same two rungs as the authorship secret and
934
+ * never from argv (a key in `ps` is a published key).
935
+ *
936
+ * It is read from `AEGIS_AI_SECRET`, then `--ai-secret-file`. Deliberately a
937
+ * separate variable from `AEGIS_FINGERPRINT_SECRET`: the authorship key seals the
938
+ * record, the AI key names the mind that answered, and a single variable would
939
+ * make "sealed by" and "ran on" the same value by default — the conflation the two
940
+ * fields exist to prevent.
941
+ */
942
+ function readAiSecret(argv, env) {
943
+ if (env.AEGIS_AI_SECRET) return String(env.AEGIS_AI_SECRET).trim();
944
+ const i = argv.indexOf('--ai-secret-file');
945
+ if (i !== -1 && argv[i + 1]) {
946
+ return fs.readFileSync(argv[i + 1], 'utf8').trim();
947
+ }
948
+ return '';
949
+ }
950
+
951
+ function readSecret(argv, env) {
952
+ if (env.AEGIS_FINGERPRINT_SECRET) return String(env.AEGIS_FINGERPRINT_SECRET).trim();
953
+ const i = argv.indexOf('--secret-file');
954
+ if (i !== -1 && argv[i + 1]) {
955
+ return fs.readFileSync(argv[i + 1], 'utf8').trim();
956
+ }
957
+ return '';
958
+ }
959
+
960
+ function flag(argv, name, fallback = null) {
961
+ const i = argv.indexOf(name);
962
+ return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : fallback;
963
+ }
964
+
965
+ function main(argv, env = process.env) {
966
+ const secret = readSecret(argv, env);
967
+
968
+ if (argv.includes('--verify')) {
969
+ const file = flag(argv, '--verify');
970
+ if (!file) throw new Error('fingerprint: --verify needs a record file');
971
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
972
+ const result = verify(record, secret);
973
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
974
+ return result.ok ? 0 : 1;
975
+ }
976
+
977
+ const obsFile = flag(argv, '--observations');
978
+ const observations = obsFile ? JSON.parse(fs.readFileSync(obsFile, 'utf8')) : [];
979
+
980
+ // The thumb profile comes from `tools/aegis-touchpad.mjs capture`. Only its
981
+ // medians are used; its own `signature` field is recomputed, not believed.
982
+ const touchFile = flag(argv, '--touch');
983
+ const touch = touchFile ? JSON.parse(fs.readFileSync(touchFile, 'utf8')) : null;
984
+
985
+ // The AI half: which mind answered, and what it did. The observations file is a
986
+ // separate one because the two axes are separate claims — a session log's
987
+ // `reply` text is not an AI observation, and `--ai-observations` is the caller
988
+ // saying it actually has some.
989
+ const aiObsFile = flag(argv, '--ai-observations');
990
+ const aiObservations = aiObsFile ? JSON.parse(fs.readFileSync(aiObsFile, 'utf8')) : null;
991
+
992
+ const record = mint({
993
+ secret,
994
+ label: flag(argv, '--label', ''),
995
+ account: flag(argv, '--account', ''),
996
+ model: flag(argv, '--model', ''),
997
+ tools: (flag(argv, '--tools', '') || '').split(',').filter(Boolean),
998
+ observations,
999
+ touch,
1000
+ aiSecret: readAiSecret(argv, env),
1001
+ aiModel: flag(argv, '--ai-model', ''),
1002
+ aiObservations,
1003
+ });
1004
+ process.stdout.write(JSON.stringify(record, null, 2) + '\n');
1005
+ return 0;
1006
+ }
1007
+
1008
+ const API = {
1009
+ // identity
1010
+ mint,
1011
+ verify,
1012
+ attest,
1013
+ attestationBody,
1014
+ id,
1015
+ same,
1016
+ describe,
1017
+ // pieces
1018
+ keyFingerprint,
1019
+ behaviouralSignature,
1020
+ aiSignature,
1021
+ aiAxisFrom,
1022
+ observationKey,
1023
+ countObservations,
1024
+ normaliseTools,
1025
+ safeLabel,
1026
+ touchSignatureFrom,
1027
+ normaliseCodePath,
1028
+ codeFileDigest,
1029
+ codeDigest,
1030
+ codeAxisFrom,
1031
+ // guards & constants
1032
+ canonical,
1033
+ assertNoLeak,
1034
+ assertInputsClean,
1035
+ longHexRun,
1036
+ isFingerprintShape,
1037
+ attestedFieldsFor,
1038
+ FP_VERSION,
1039
+ FP_ALGO,
1040
+ FP_PREFIX,
1041
+ KEY_FP_LENGTH,
1042
+ SIG_LENGTH,
1043
+ ATTEST_LENGTH,
1044
+ LEAK_HEX_RUN,
1045
+ CODE_LEN,
1046
+ MAX_CODE_FILES,
1047
+ MAX_PATH,
1048
+ BEHAVIOUR_PURPOSE,
1049
+ AI_PURPOSE,
1050
+ ATTESTED_FIELDS,
1051
+ ATTESTED_FIELDS_V1,
1052
+ ATTESTED_FIELDS_V2,
1053
+ ATTESTED_FIELDS_V3,
1054
+ ATTESTED_FIELDS_V4,
1055
+ ATTESTED_FIELDS_BY_VERSION,
1056
+ main,
1057
+ };
1058
+
1059
+
1060
+
1061
+ module.exports = API;