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,439 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * attest.js — turn a session into a φ(α) record.
5
+ *
6
+ * `client/fingerprint.js` knows how to seal a record from *inputs*; it knows
7
+ * nothing about where those inputs live, and that is deliberate — the vendored
8
+ * module must stay byte-identical to the engine's, so nothing host-specific can
9
+ * be added to it. This file is that missing half, shared by the CLI and the
10
+ * desktop so both hosts attest a session the same way: the same log is read the
11
+ * same way, thumbed into the same observations, and sealed under the same key.
12
+ *
13
+ * ## What the fields may be
14
+ *
15
+ * The engine's rule (see `src/fingerprint.js`) is that a record may carry only
16
+ * what was actually observed: a fabricated field is not evidence. Carried over
17
+ * literally here, that means this module never invents a value to fill a shape:
18
+ *
19
+ * - a session log entry that carries no recognisable working directory
20
+ * contributes `dir: null`, not `process.cwd()`;
21
+ * - a turn with no recorded outcome contributes `outcome: null`, not `'ok'`;
22
+ * - `--turns N` that selects more turns than exist attests the turns there
23
+ * are, and the record's `behaviourSamples` then says how many that was.
24
+ *
25
+ * A null field is a true statement about the log ("the log did not say"); a
26
+ * guessed one is a false statement about the session. The engine's `verify`
27
+ * compares the attestation over these fields, so the difference is not
28
+ * cosmetic — it is the difference between a record that reproduces and one
29
+ * that does not.
30
+ *
31
+ * ## Schema tolerance, and why it stops where it does
32
+ *
33
+ * The session log has carried more than one entry shape across versions, so
34
+ * each turn is read through a small ordered list of aliases (`cwd`/`dir`,
35
+ * `demand`/`prompt`/…). Unknown keys are ignored rather than merged in, because
36
+ * an unknown key is a key nobody has agreed on the meaning of, and including it
37
+ * would make the behavioural axis change silently the first time a log gained a
38
+ * field. Adding an alias here is a deliberate act with a test behind it.
39
+ */
40
+
41
+ const crypto = require('node:crypto');
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+
45
+ const fp = require('./fingerprint.js');
46
+ const authorship = require('./authorship-key.js');
47
+
48
+ const DEFAULT_HISTORY = () => path.join(authorship.dataDir(), 'history.jsonl');
49
+
50
+ /** Ordered aliases. First present key wins; a later alias never overrides one. */
51
+ const DIR_KEYS = ['cwd', 'dir', 'workspace', 'root'];
52
+ const DEMAND_KEYS = ['demand', 'prompt', 'task', 'text', 'content'];
53
+ const OUTCOME_KEYS = ['outcome', 'status', 'result'];
54
+ const SESSION_KEYS = ['session', 'sessionId', 'session_id'];
55
+ const TIME_KEYS = ['at', 'ts', 'time', 'createdAt'];
56
+
57
+ /**
58
+ * The AI half's aliases. Separate lists from the human half's on purpose: `text`
59
+ * is a demand in `DEMAND_KEYS` and a reply would be one here, and a shared list
60
+ * would let one axis read the other's field — which is exactly the conflation the
61
+ * two signatures exist to prevent.
62
+ */
63
+ const REPLY_KEYS = ['reply', 'answer', 'response'];
64
+ const MODEL_KEYS = ['model', 'aiModel', 'modelId'];
65
+
66
+ /** `sha256(text)[0:8]` — a content address, never the content. */
67
+ const sha256hex = (text) => crypto.createHash('sha256').update(String(text || ''), 'utf8').digest('hex');
68
+
69
+ /** Where the AI credential comes from: `--ai-secret FILE`, then `AEGIS_AI_SECRET`. */
70
+ const AI_SECRET_ENV = 'AEGIS_AI_SECRET';
71
+
72
+ function firstKey(entry, keys) {
73
+ for (const k of keys) {
74
+ const v = entry[k];
75
+ if (v === undefined || v === null) continue;
76
+ const s = typeof v === 'string' ? v : String(v);
77
+ if (s.trim() !== '') return s;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Read a session log.
84
+ *
85
+ * A malformed line is skipped, not fatal: a half-written last line is the
86
+ * normal state of an append-only log that was interrupted, and refusing the
87
+ * whole file over it would mean the sessions with the most history — the ones
88
+ * worth attesting — are the ones that cannot be.
89
+ */
90
+ function readHistory(file) {
91
+ if (!fs.existsSync(file)) throw new Error(`attest: no session log at ${file}`);
92
+ const entries = [];
93
+ let skipped = 0;
94
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
95
+ const text = line.trim();
96
+ if (!text) continue;
97
+ try {
98
+ const parsed = JSON.parse(text);
99
+ if (parsed && typeof parsed === 'object') entries.push(parsed);
100
+ else skipped += 1;
101
+ } catch {
102
+ skipped += 1;
103
+ }
104
+ }
105
+ return { entries, skipped };
106
+ }
107
+
108
+ /** The last session id the log mentions, which is what "current" means. */
109
+ function lastSessionId(entries) {
110
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
111
+ const id = firstKey(entries[i], SESSION_KEYS);
112
+ if (id) return id;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Select the turns to attest.
119
+ *
120
+ * With no `session`, the last session in the log is used — attributed rather
121
+ * than assumed: turns whose session id is missing are included only when no
122
+ * session was requested, since "which session is this?" is exactly the question
123
+ * they cannot answer.
124
+ */
125
+ function selectTurns(entries, { session = null, turns = null } = {}) {
126
+ let chosen = entries;
127
+ if (session) {
128
+ const wanted = String(session);
129
+ chosen = entries.filter((e) => firstKey(e, SESSION_KEYS) === wanted);
130
+ }
131
+ if (turns && Number.isFinite(Number(turns)) && Number(turns) > 0) {
132
+ chosen = chosen.slice(-Math.floor(Number(turns)));
133
+ }
134
+ return chosen;
135
+ }
136
+
137
+ /**
138
+ * Fold turns into observations.
139
+ *
140
+ * The keys are the ENGINE's (`fp.observationKey` reads `tool`, `target`,
141
+ * `outcome`, `model`) and nothing else, because an observation is hashed by
142
+ * reading those four fields and a field with another name is silently dropped —
143
+ * it never reaches the signature it was handed over to feed. An earlier version of
144
+ * this function emitted `{dir, demand, outcome}` and read as if the demand were
145
+ * attested; it was not, so the behavioural axis of every record minted from a
146
+ * session log described the turn outcomes alone.
147
+ *
148
+ * So the two true things this log says about a turn are carried as the engine's
149
+ * fields: where it ran and what was asked for. `target` is a content address of
150
+ * the demand rather than the demand itself — the signature exists to compare
151
+ * agents, not to archive what they were told — and it is addressed exactly as
152
+ * `aegiscodex`'s own tool addresses it, so the two hosts read one session log into
153
+ * one digest.
154
+ *
155
+ * `outcome` stays `null` when the log did not record one, and a null field
156
+ * contributes no signal: `behaviouralSignature` drops an observation that carries
157
+ * nothing, and `behaviourSamples` counts what was actually attested.
158
+ */
159
+ function observationsFromTurns(turns) {
160
+ const out = [];
161
+ for (const t of Array.isArray(turns) ? turns : []) {
162
+ if (!t || typeof t !== 'object') continue;
163
+ const dir = firstKey(t, DIR_KEYS);
164
+ const demand = firstKey(t, DEMAND_KEYS);
165
+ out.push({
166
+ tool: demand || dir ? 'turn' : null,
167
+ target: `${dir ? path.basename(dir) : '?'}#${sha256hex(demand || '').slice(0, 8)}`,
168
+ outcome: firstKey(t, OUTCOME_KEYS),
169
+ });
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Fold the same turns from the *reply* side: the AI axis's observations.
176
+ *
177
+ * One observation per turn, carrying only what the entry records about the
178
+ * answer — the tool it named (usually none), the working directory it ran in, the
179
+ * outcome, and the model that produced it. Every one of those is `null` when the
180
+ * log did not say, and `null` contributes no signal: `fp.aiSignature` hashes only
181
+ * the observations that carry something, and `aiSamples` counts those. A log that
182
+ * records nothing about the answers therefore yields `ai: null, aiSamples: 0` —
183
+ * "the log did not say" — rather than a signature over a row of empty fields.
184
+ *
185
+ * The reply text is deliberately NOT hashed. It would be a transcript hash wearing
186
+ * a behavioural signature's name: two agents answering the same demand in
187
+ * different words would stop matching, and the axis is meant to compare habits,
188
+ * not prose. `--ai-observations` (the engine CLI) is where a caller who has real
189
+ * tool-level AI observations passes them.
190
+ */
191
+ function aiObservationsFromTurns(turns) {
192
+ const out = [];
193
+ for (const t of Array.isArray(turns) ? turns : []) {
194
+ if (!t || typeof t !== 'object') continue;
195
+ const reply = firstKey(t, REPLY_KEYS);
196
+ const status = firstKey(t, OUTCOME_KEYS);
197
+ const model = firstKey(t, MODEL_KEYS);
198
+ // "Answered" is evidence about the ANSWER. A turn the log recorded with no
199
+ // reply, no status and no model contributes an all-null observation, which
200
+ // `fp.aiSignature` drops: "the log never said" must stay distinct from a
201
+ // signature over a pretend answer, and the working directory the human side
202
+ // already carries must not be re-read as if it were the AI's evidence.
203
+ const answered = Boolean(reply || status || model);
204
+ out.push({
205
+ tool: answered ? 'reply' : null,
206
+ // The reply is addressed, not archived — the same discipline the engine tool
207
+ // uses for a demand, and it is why both hosts read one log into one digest.
208
+ target: answered ? `reply#${sha256hex(reply || '').slice(0, 8)}` : null,
209
+ outcome: status || null,
210
+ model,
211
+ });
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * Resolve the AI credential: `--ai-secret FILE`, then `$AEGIS_AI_SECRET`, else no
218
+ * key.
219
+ *
220
+ * `--ai-secret` is an alias for the engine's `--ai-secret-file` spelling, and both
221
+ * are checked because the two hosts' users read different docs. The authorship
222
+ * ladder is NOT consulted: `keyFingerprint` of the sealing key would make every
223
+ * record claim it ran on its author's key, and "which mind answered" would stop
224
+ * being a question with an answer. No key nominated ⇒ `null`, which the record
225
+ * states plainly.
226
+ */
227
+ function resolveAiSecret(o = {}) {
228
+ const argv = o.argv || [];
229
+ const env = o.env || {};
230
+ const file = firstFlag(argv, ['--ai-secret', '--ai-secret-file']);
231
+ if (file) {
232
+ const secret = fs.readFileSync(file, 'utf8').trim();
233
+ if (!secret) throw new Error(`attest: ${file} is empty — an empty AI credential is not one`);
234
+ return { secret, source: file };
235
+ }
236
+ const envSecret = env[AI_SECRET_ENV] && String(env[AI_SECRET_ENV]).trim();
237
+ if (envSecret) return { secret: envSecret, source: AI_SECRET_ENV };
238
+ return { secret: null, source: 'none' };
239
+ }
240
+
241
+ /** `--flag V`, first spelling that matches. Local so this file keeps no argv helper. */
242
+ function firstFlag(argv, names) {
243
+ for (const name of names) {
244
+ const i = argv.indexOf(name);
245
+ if (i !== -1 && argv[i + 1] !== undefined && !String(argv[i + 1]).startsWith('--')) {
246
+ return argv[i + 1];
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Walk a directory and return `{path, content}` records for the code axis.
254
+ *
255
+ * The key names are the engine's (`codeDigest` reads `f.path`), not this
256
+ * module's choice: a mismatch here yields `codeFiles: 0` and a null axis —
257
+ * silently, since the engine treats an unusable path as "not a file" rather
258
+ * than as an error. `test/fingerprint.test.mjs` pins the count for that reason.
259
+ *
260
+ * Symlinks are not followed and the noisy trees are skipped, matching the
261
+ * engine tool: the axis is meant to say *which revision of which files*, and a
262
+ * walk that followed a symlink out of the declared root, or dragged in
263
+ * `node_modules`, would attest something other than the user's code. An
264
+ * oversized file throws rather than attesting a hole in the digest.
265
+ */
266
+ const SKIP_DIRS = new Set([
267
+ '.git',
268
+ 'node_modules',
269
+ 'dist',
270
+ 'build',
271
+ 'out',
272
+ 'vendor',
273
+ '.venv',
274
+ 'venv',
275
+ '__pycache__',
276
+ '.next',
277
+ '.cache',
278
+ 'release',
279
+ 'coverage',
280
+ ]);
281
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
282
+ const MAX_FILES = 4096;
283
+
284
+ function collectCode({ roots = [], files = [], skip = [] } = {}) {
285
+ const extraSkip = new Set(skip);
286
+ const out = [];
287
+ const add = (relpath, abs) => {
288
+ const st = fs.statSync(abs);
289
+ if (!st.isFile()) return;
290
+ if (st.size > MAX_FILE_BYTES) {
291
+ throw new Error(`attest: ${abs} is ${st.size} bytes — too large to attest (max ${MAX_FILE_BYTES})`);
292
+ }
293
+ if (out.length >= MAX_FILES) {
294
+ throw new Error(`attest: more than ${MAX_FILES} files — refusing to attest a partial walk`);
295
+ }
296
+ const rel = fp.normaliseCodePath(relpath);
297
+ if (!rel) return; // a path the engine cannot use is not a code file
298
+ out.push({ path: rel, content: fs.readFileSync(abs, 'utf8') });
299
+ };
300
+
301
+ for (const root of roots) {
302
+ const abs = path.resolve(root);
303
+ const walk = (dir, prefix) => {
304
+ for (const name of fs.readdirSync(dir).sort()) {
305
+ const full = path.join(dir, name);
306
+ const rel = prefix ? `${prefix}/${name}` : name;
307
+ const st = fs.lstatSync(full);
308
+ if (st.isSymbolicLink()) continue; // never leave the declared root
309
+ if (st.isDirectory()) {
310
+ if (SKIP_DIRS.has(name) || extraSkip.has(name)) continue;
311
+ walk(full, rel);
312
+ continue;
313
+ }
314
+ add(rel, full);
315
+ }
316
+ };
317
+ walk(abs, '');
318
+ }
319
+
320
+ for (const file of files) {
321
+ const abs = path.resolve(file);
322
+ add(path.basename(abs), abs);
323
+ }
324
+
325
+ return out;
326
+ }
327
+
328
+ /**
329
+ * Mint a record for a session.
330
+ *
331
+ * @param {object} o
332
+ * @param {string[]} [o.argv] flags; `--secret`/`--secret-file` are read here
333
+ * @param {object} [o.env]
334
+ * @param {string} [o.history] session log (default `$AEGIS_HOME/history.jsonl`)
335
+ * @param {string} [o.session] session id (default: the last one in the log)
336
+ * @param {number} [o.turns] only the last N turns
337
+ * @param {string} [o.label]
338
+ * @param {string} [o.account]
339
+ * @param {string} [o.model]
340
+ * @param {string[]} [o.tools]
341
+ * @param {object[]} [o.code] output of `collectCode`
342
+ * @param {boolean?} [o.codeDirty]
343
+ * @param {string} [o.createdAt] pin the record's timestamp
344
+
345
+ * `createdAt` is a pass-through to the engine's `mint`, which otherwise stamps
346
+ * the record with the current time. That default is right for a session being
347
+ * sealed now and wrong for the one thing this module is used for besides that:
348
+ * two hosts attesting the *same* session must produce the *same* record, and
349
+ * they cannot if each stampes its own millisecond. It is also what lets a
350
+ * record be re-sealed at the time it originally described. It is deliberately
351
+ * not exposed as a CLI flag — a hand-set timestamp on an ordinary mint would
352
+ * let a caller backdate evidence, which is exactly what the record exists to
353
+ * prevent.
354
+ * @param {string|null} [o.aiSecret] the credential the session ran on. Passed
355
+ * in, resolved from `--ai-secret`/
356
+ * `$AEGIS_AI_SECRET`, or explicitly null.
357
+ * @param {string} [o.aiModel] the model that answered.
358
+ * @param {object[]} [o.aiObservations] the replies' observations; derived from
359
+ * the turns when omitted.
360
+ * @returns {{record: object, keySource: string, public: boolean, samples: number,
361
+ * observations: number, aiKeySource: string, aiSamples: number,
362
+ * skippedLines: number}}
363
+ */
364
+ function mintSession(o = {}) {
365
+ const key = o.key || authorship.resolveKey(o);
366
+ if (!key.secret) {
367
+ throw new Error(
368
+ 'attest: no authorship key — run `aegiscode fingerprint keygen`, set ' +
369
+ `${authorship.SECRET_ENV}, or pass --secret <file>`,
370
+ );
371
+ }
372
+
373
+ const history = o.history || DEFAULT_HISTORY();
374
+ const { entries, skipped } = readHistory(history);
375
+ const session = o.session || lastSessionId(entries);
376
+ const turns = selectTurns(entries, { session, turns: o.turns });
377
+ const observations = observationsFromTurns(turns);
378
+
379
+ const code = o.code || [];
380
+
381
+ // The AI axis. `aiSecret` may be passed in (a host that already resolved it),
382
+ // left undefined (then the two rungs above are walked), or explicitly null (a
383
+ // host saying "no credential — mint the axis as unobserved").
384
+ const aiKey = o.aiSecret === undefined ? resolveAiSecret(o) : { secret: o.aiSecret, source: o.aiSecret ? 'caller' : 'none' };
385
+ const aiObservations = o.aiObservations || aiObservationsFromTurns(turns);
386
+ const aiModel = o.aiModel !== undefined && o.aiModel !== null
387
+ ? o.aiModel
388
+ : aiObservations.map((a) => a.model).filter(Boolean).pop() || '';
389
+
390
+ const record = fp.mint({
391
+ secret: key.secret,
392
+ label: o.label,
393
+ account: o.account,
394
+ model: o.model,
395
+ tools: o.tools,
396
+ observations,
397
+ code,
398
+ codeDirty: o.codeDirty === undefined ? null : o.codeDirty,
399
+ aiSecret: aiKey.secret,
400
+ aiModel,
401
+ aiObservations,
402
+ createdAt: o.createdAt || undefined,
403
+ });
404
+
405
+ return {
406
+ record,
407
+ keySource: key.source,
408
+ public: key.public,
409
+ samples: record.behaviourSamples,
410
+ observations: observations.length,
411
+ aiKeySource: aiKey.source,
412
+ aiSamples: record.aiSamples,
413
+ skippedLines: skipped,
414
+ };
415
+ }
416
+
417
+ module.exports = {
418
+ DEFAULT_HISTORY,
419
+ DIR_KEYS,
420
+ DEMAND_KEYS,
421
+ OUTCOME_KEYS,
422
+ SESSION_KEYS,
423
+ REPLY_KEYS,
424
+ MODEL_KEYS,
425
+ AI_SECRET_ENV,
426
+ TIME_KEYS,
427
+ SKIP_DIRS,
428
+ MAX_FILE_BYTES,
429
+ MAX_FILES,
430
+ firstKey,
431
+ readHistory,
432
+ lastSessionId,
433
+ selectTurns,
434
+ observationsFromTurns,
435
+ aiObservationsFromTurns,
436
+ resolveAiSecret,
437
+ collectCode,
438
+ mintSession,
439
+ };