attesso 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/attesso.mjs CHANGED
@@ -7,11 +7,28 @@
7
7
  // attesso verify
8
8
  // attesso show
9
9
  import { verifyChain } from './../src/ledger.mjs';
10
- import { formatMinor, toMinor } from './../src/policy.mjs';
11
- import { append, init, isInitialised, paths, readState } from './../src/store.mjs';
12
- import { buildReport, renderReport } from './../src/report.mjs';
10
+ import { bundleFor, verifyBundle } from './../src/bundle.mjs';
11
+ import { formatMinor, toMinor, validatePolicy } from './../src/policy.mjs';
12
+ import { checkpointFor, receiptFor, signerOf, verifyPinned, verifyReceipt } from './../src/receipt.mjs';
13
+ import { append, init, isInitialised, paths, readState, readToolLock, setRulesProfile, verifyLedger, withLedgerLock, writeMcpManifest, writeToolLock } from './../src/store.mjs';
14
+ import { buildReportStream, renderReport } from './../src/report.mjs';
15
+ import { replay, renderReplay } from './../src/replay.mjs';
16
+ import { watchEntries } from './../src/watch.mjs';
13
17
  import { serve } from './../src/serve.mjs';
14
- import { decide } from './../src/hook.mjs';
18
+ import { createHookDecider } from './../src/hook.mjs';
19
+ import { HARNESSES, normalizeHookPayload, renderHook } from './../src/harness.mjs';
20
+ import { clearTaskControl, listControl, setTaskControl } from './../src/control.mjs';
21
+ import { detectHarnesses, explain, suggest, writeAgentContext } from './../src/setup.mjs';
22
+ import { CONNECT_MODES, connect as connectAgent, readMode, setModeAll, status as agentStatus } from './../src/connect.mjs';
23
+ import { wireHarness } from './../src/install.mjs';
24
+ import { loadTools } from './../src/classify.mjs';
25
+ import { comparePin, pinManifest } from './../src/toolpin.mjs';
26
+ import { flags, parseMeta, publicKey, readJsonFile, writeJsonOut } from './../src/cli-io.mjs';
27
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
28
+ import { resolve } from 'node:path';
29
+ import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
30
+ import { validatePathsConfig } from './../src/fs-paths.mjs';
31
+ import { startUi } from './../src/ui/server.mjs';
15
32
 
16
33
  const argv = process.argv.slice(2);
17
34
  const dirAt = argv.indexOf('--dir');
@@ -20,17 +37,50 @@ if (dirAt !== -1) argv.splice(dirAt, 2);
20
37
 
21
38
  const USAGE = `attesso: a local, signed ledger of what your agents attempted
22
39
 
23
- attesso init [--dir PATH] create .attesso with a key and a policy
40
+ attesso init [--dir PATH] [--profile balanced|strict]
41
+ create .attesso with a key and a policy
24
42
  attesso log --action A [--subject S] [--amount 12.50 --currency EUR]
25
- [--task T] [--trust internal|untrusted] [--capability read|send|execute|spend]
43
+ [--task T] [--trust internal|untrusted] [--capability read|write|send|execute|spend]
26
44
  [--meta k=v ...] [--json]
27
45
  evaluate one proposed action against the policy
28
46
  and the path of its task, then record it
29
47
  attesso verify recompute the chain and report the first defect
48
+ attesso verify --pin PATH also check the log extends a pinned checkpoint
49
+ attesso rules set-profile balanced|strict
50
+ adopt a shipped rule profile, keeping the old file as .bak
51
+ attesso pause <task> [--reason TEXT] refuse the next actions of one task
52
+ attesso stop <task> [--reason TEXT] refuse one task until it is resumed
53
+ attesso resume <task> release a paused or stopped task
54
+ attesso control [--json] list paused and stopped tasks
55
+ attesso watch [--from-seq N] [--task ID] [--json]
56
+ follow new decisions in one terminal
57
+ attesso prove <seq> [--out PATH] a portable receipt for one entry
58
+ attesso verify-receipt PATH [--key PATH]
59
+ verify a receipt offline; no ledger needed
60
+ attesso checkpoint [--out PATH] a signed head you can keep off this machine
61
+ attesso export <seq> [--out PATH] entry, receipt, policy and rules in one bundle
62
+ attesso verify-bundle PATH [--replay]
63
+ verify a bundle offline, and re-run the verdict
30
64
  attesso show what was allowed, what was refused, and why
31
65
  attesso report [--json] per task: actions, refusals with reasons, tokens and cost
66
+ attesso replay [--policy PATH] [--rules PATH] [--json]
67
+ [--max-newly-denied N] re-judge the whole ledger under a candidate policy;
68
+ exit 1 when the gate is exceeded
32
69
  attesso serve [--observe] long-lived: one JSON request per line in, one verdict out
33
- attesso hook [--observe] harness hook: reads one tool call on stdin, exits 2 to refuse
70
+ attesso hook [--observe] harness hook: one tool call on stdin, native refusal on stdout
71
+ attesso setup [--write] [--enforce] [--dry-run]
72
+ detect a harness, wire the hook, write the agent block
73
+ attesso connect [--write] [--mode observe|enforce|off] [--json]
74
+ self-service: init, mode, hook and AGENTS.md
75
+ attesso status [--json] connection, mode and wired harnesses
76
+ attesso mode [observe|enforce|off] read or set the active hook mode
77
+ attesso paths [--json|--set FILE] read or replace the filesystem scope
78
+ attesso ui [--port N] [--no-open] local control surface for the signed ledger
79
+ attesso explain <seq> one entry in full: rule, tainting step, what to do
80
+ attesso suggest [--json] rules the ledger's own counts justify
81
+ attesso tools pin --manifest PATH pin the tool definitions a decision was made under
82
+ attesso tools verify --manifest PATH
83
+ compare the pinned definitions with the manifest
34
84
 
35
85
  Any action can be logged, not only payments. --subject is the thing acted on (a
36
86
  tool name, a payee, a repo). --task scopes the path, --trust marks where the
@@ -43,39 +93,6 @@ The log is signed with a key that lives in .attesso/key.json. That proves the
43
93
  ledger was not edited; it does not prove a human authorized anything. Hosted
44
94
  Attesso adds passkey approval and evidence a third party verifies offline.`;
45
95
 
46
- function flags(argv) {
47
- const out = {};
48
- for (let index = 0; index < argv.length; index += 1) {
49
- const token = argv[index];
50
- if (!token.startsWith('--')) continue;
51
- const name = token.slice(2);
52
- const next = argv[index + 1];
53
- if (next === undefined || next.startsWith('--')) {
54
- out[name] = true;
55
- continue;
56
- }
57
- if (name === 'meta') {
58
- out.meta = [...(out.meta ?? []), next];
59
- } else {
60
- out[name] = next;
61
- }
62
- index += 1;
63
- }
64
- return out;
65
- }
66
-
67
- function parseMeta(pairs = []) {
68
- const meta = {};
69
- for (const pair of pairs) {
70
- const at = pair.indexOf('=');
71
- if (at <= 0) throw new Error(`--meta expects key=value, got ${pair}`);
72
- const key = pair.slice(0, at);
73
- const raw = pair.slice(at + 1);
74
- meta[key] = raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
75
- }
76
- return meta;
77
- }
78
-
79
96
  function requireLedger() {
80
97
  if (!isInitialised(DIR)) {
81
98
  console.error(`no ledger at ${paths(DIR).dir}; run attesso init first`);
@@ -89,6 +106,54 @@ function describe(entry) {
89
106
  return `${entry.action}${subject}${amount}`;
90
107
  }
91
108
 
109
+ function renderWatchEntry(entry) {
110
+ const at = typeof entry.at === 'string' ? entry.at.slice(11, 19) : '--:--:--';
111
+ const rule = entry.rule ? ` ${entry.rule}` : '';
112
+ const control = entry.control ? ` control=${entry.control.mode}` : '';
113
+ const task = entry.task ? ` task=${String(entry.task).slice(0, 12)}` : '';
114
+ return `#${entry.seq} ${at} ${String(entry.decision).padEnd(5)} ${entry.subject ?? '(none)'} ${entry.capability ?? ''} ${entry.code}${rule}${task}${control}`;
115
+ }
116
+
117
+ function toolsCommand(rest) {
118
+ requireLedger();
119
+ const [subcommand, ...args] = rest;
120
+ const options = flags(args);
121
+ if ((subcommand !== 'pin' && subcommand !== 'verify') || typeof options.manifest !== 'string') {
122
+ console.error('usage: attesso tools pin|verify --manifest PATH');
123
+ process.exit(2);
124
+ }
125
+ const manifest = readJsonFile(options.manifest, 'tools');
126
+ try {
127
+ const tools = loadTools(DIR);
128
+ if (subcommand === 'pin') {
129
+ const pin = pinManifest(manifest, tools);
130
+ writeMcpManifest(DIR, manifest);
131
+ writeToolLock(DIR, pin);
132
+ const surfaces = ['server', 'prompts', 'resources', 'resource_templates'].filter((field) => pin[`${field}_digest`]);
133
+ console.log(`pinned ${Object.keys(pin.tools).length} tools${surfaces.length ? ` and ${surfaces.join(', ')}` : ''}; tools digest ${pin.tools_digest}`);
134
+ return;
135
+ }
136
+ const lock = readToolLock(DIR);
137
+ if (!lock) {
138
+ console.error('no tool pin at .attesso/tools.lock.json; run attesso tools pin first');
139
+ process.exit(1);
140
+ }
141
+ const result = comparePin(lock, manifest, tools);
142
+ if (!result.ok) {
143
+ if (result.digestChanged) console.error('tools.json changed since the pin');
144
+ for (const name of result.changed) console.error(`changed tool: ${name}`);
145
+ for (const name of result.added) console.error(`added tool: ${name}`);
146
+ for (const name of result.removed) console.error(`removed tool: ${name}`);
147
+ for (const surface of result.surfaces) console.error(`changed ${surface} surface`);
148
+ process.exit(1);
149
+ }
150
+ console.log(`tool pin verifies: ${Object.keys(lock.tools).length} tools`);
151
+ } catch (error) {
152
+ console.error(`tools: ${error.message}`);
153
+ process.exit(1);
154
+ }
155
+ }
156
+
92
157
  function log(argv) {
93
158
  requireLedger();
94
159
  const options = flags(argv);
@@ -117,12 +182,12 @@ function log(argv) {
117
182
  process.exit(2);
118
183
  }
119
184
 
120
- if (options.trust !== undefined && !['internal', 'untrusted', 'unknown'].includes(options.trust)) {
121
- console.error('--trust takes internal, untrusted or unknown');
185
+ if (options.trust !== undefined && !TRUST_LEVELS.includes(options.trust)) {
186
+ console.error(`--trust takes ${TRUST_LEVELS.join(', ')}`);
122
187
  process.exit(2);
123
188
  }
124
- if (options.capability !== undefined && !['read', 'send', 'execute', 'spend'].includes(options.capability)) {
125
- console.error('--capability takes read, send, execute or spend');
189
+ if (options.capability !== undefined && !CAPABILITIES.includes(options.capability)) {
190
+ console.error(`--capability takes ${CAPABILITIES.join(', ')}`);
126
191
  process.exit(2);
127
192
  }
128
193
  const entry = append(DIR, {
@@ -138,20 +203,94 @@ function log(argv) {
138
203
  console.log(JSON.stringify(entry));
139
204
  return;
140
205
  }
141
- const taint = entry.meta?.tainted_by === undefined ? '' : ` by step #${entry.meta.tainted_by} under ${entry.meta.rule}`;
206
+ const taint = entry.tainted_by === undefined ? '' : ` by step #${entry.tainted_by} under ${entry.rule}`;
142
207
  console.log(`#${entry.seq} ${entry.decision} ${describe(entry)} (${entry.code}${taint})`);
143
208
  process.exitCode = entry.decision === 'ALLOW' ? 0 : 1;
144
209
  }
145
210
 
146
- function verify() {
211
+ function verify(rest = []) {
147
212
  requireLedger();
213
+ const options = flags(rest);
214
+ if (options.pin !== undefined) {
215
+ let state;
216
+ let result;
217
+ try {
218
+ // Verify a snapshot, not a writer's in-flight line: the lock makes the
219
+ // line and the head it belongs to visible together.
220
+ state = withLedgerLock(DIR, () => readState(DIR));
221
+ result = verifyChain(state.entries, state.key.public, state.config.head);
222
+ } catch {
223
+ // A read-only archive cannot take the lock; there is no writer to race.
224
+ state = readState(DIR);
225
+ result = verifyChain(state.entries, state.key.public, state.config.head);
226
+ }
227
+ if (!result.ok) {
228
+ console.error(`ledger does not verify: ${result.reason}`);
229
+ process.exit(1);
230
+ }
231
+ console.log(`ledger verifies: ${result.reason}, key ${state.key.kid}`);
232
+ const pinned = readJsonFile(options.pin, 'verify --pin');
233
+ const check = verifyPinned(state.entries, pinned, state.key.public);
234
+ if (!check.ok) {
235
+ console.error(`pinned head does not verify: ${check.reason}`);
236
+ process.exit(1);
237
+ }
238
+ console.log(`pinned head: ${check.reason}`);
239
+ return;
240
+ }
241
+ return (async () => {
242
+ let result;
243
+ for (let attempt = 0; attempt < 10; attempt += 1) {
244
+ result = await verifyLedger(DIR);
245
+ if (result.ok) break;
246
+ // A writer can be between its signed line and the head update. The lock
247
+ // used to hide that window; a streamed read waits it out instead.
248
+ if (!/more .* than the recorded head|last entry does not match|Unexpected end of JSON input|Unexpected token|Expected property name/.test(result.reason)) break;
249
+ await new Promise((resolve) => setTimeout(resolve, 25));
250
+ }
251
+ if (!result.ok) {
252
+ console.error(`ledger does not verify: ${result.reason}`);
253
+ process.exit(1);
254
+ }
255
+ console.log(`ledger verifies: ${result.reason}, key ${result.kid}`);
256
+ })();
257
+ }
258
+
259
+ function prove(rest) {
260
+ requireLedger();
261
+ const options = flags(rest);
262
+ const seq = Number(rest[0]);
148
263
  const state = readState(DIR);
149
- const result = verifyChain(state.entries, state.key.public, state.config.head);
264
+ const index = state.entries.findIndex((entry) => entry.seq === seq);
265
+ if (index === -1) {
266
+ console.error(`no entry #${seq} in this ledger`);
267
+ process.exit(1);
268
+ }
269
+ writeJsonOut(DIR, receiptFor(state.entries, index, signerOf(state.key)), options.out);
270
+ }
271
+
272
+ function checkpoint(rest) {
273
+ requireLedger();
274
+ const options = flags(rest);
275
+ const state = readState(DIR);
276
+ writeJsonOut(DIR, checkpointFor(state.entries, signerOf(state.key)), options.out);
277
+ }
278
+
279
+ function verifyReceiptCommand(rest) {
280
+ const options = flags(rest);
281
+ const file = rest[0];
282
+ if (!file) {
283
+ console.error('attesso verify-receipt <file>');
284
+ process.exit(2);
285
+ }
286
+ const receipt = readJsonFile(file, 'verify-receipt');
287
+ const result = verifyReceipt(receipt, options.key ? publicKey(options.key) : receipt.key);
150
288
  if (!result.ok) {
151
- console.error(`ledger does not verify: ${result.reason}`);
289
+ console.error(`receipt does not verify: ${result.reason}`);
152
290
  process.exit(1);
153
291
  }
154
- console.log(`ledger verifies: ${result.reason}, key ${state.key.kid}`);
292
+ const trust = options.key ? '' : ' (against the key embedded in the receipt; pass --key to pin a trusted one)';
293
+ console.log(`receipt verifies: ${result.reason}${trust}`);
155
294
  }
156
295
 
157
296
  function totals(entries) {
@@ -202,12 +341,20 @@ function show() {
202
341
  }
203
342
 
204
343
  const [command, ...rest] = argv;
344
+
345
+ process.on('uncaughtException', (error) => {
346
+ if (process.env.ATTESSO_DEBUG) throw error;
347
+ console.error(`attesso: ${error.message}`);
348
+ process.exit(1);
349
+ });
350
+
205
351
  switch (command) {
206
352
  case 'init': {
207
353
  try {
208
- const result = init(DIR);
209
- console.log(`created ${result.dir} with key ${result.kid}`);
210
- console.log('edit .attesso/policy.json to set what is allowed (it starts at nothing)');
354
+ const options = flags(rest);
355
+ const result = init(DIR, { profile: typeof options.profile === 'string' ? options.profile : 'balanced' });
356
+ console.log(`created ${result.dir} with key ${result.kid} (${result.profile} rules)`);
357
+ console.log(`review ${paths(DIR).policy}, ${paths(DIR).rules} and ${paths(DIR).tools} before enforcing`);
211
358
  } catch (error) {
212
359
  console.error(error.message);
213
360
  process.exit(1);
@@ -218,14 +365,371 @@ switch (command) {
218
365
  log(rest);
219
366
  break;
220
367
  case 'verify':
221
- verify();
368
+ await verify(rest);
369
+ break;
370
+ case 'rules': {
371
+ requireLedger();
372
+ const [subcommand, ...args] = rest;
373
+ const options = flags(args);
374
+ const profile = typeof options.profile === 'string' ? options.profile : args.find((arg) => !arg.startsWith('--'));
375
+ if (subcommand !== 'set-profile' || typeof profile !== 'string') {
376
+ console.error('usage: attesso rules set-profile balanced|strict');
377
+ process.exit(2);
378
+ }
379
+ try {
380
+ const result = setRulesProfile(DIR, profile);
381
+ const state = result.changed ? 'updated' : 'already current';
382
+ console.log(`rules ${result.profile}: ${state}; digest ${result.digest}`);
383
+ } catch (error) {
384
+ console.error(`rules: ${error.message}`);
385
+ process.exit(2);
386
+ }
387
+ break;
388
+ }
389
+ case 'checkpoint':
390
+ checkpoint(rest);
222
391
  break;
392
+ case 'pause':
393
+ case 'stop': {
394
+ requireLedger();
395
+ const options = flags(rest);
396
+ const task = rest.find((arg) => !arg.startsWith('--'));
397
+ if (!task) {
398
+ console.error(`usage: attesso ${command} <task> [--reason TEXT]`);
399
+ process.exit(2);
400
+ }
401
+ try {
402
+ const result = setTaskControl(DIR, task, command, { reason: options.reason });
403
+ console.log(`${command} ${result.task}: ${result.mode} at ${result.at}`);
404
+ } catch (error) {
405
+ console.error(`${command}: ${error.message}`);
406
+ process.exit(2);
407
+ }
408
+ break;
409
+ }
410
+ case 'resume': {
411
+ requireLedger();
412
+ const task = rest.find((arg) => !arg.startsWith('--'));
413
+ if (!task) {
414
+ console.error('usage: attesso resume <task>');
415
+ process.exit(2);
416
+ }
417
+ try {
418
+ const changed = clearTaskControl(DIR, task);
419
+ console.log(changed ? `resumed ${task}` : `${task} was not paused or stopped`);
420
+ } catch (error) {
421
+ console.error(`resume: ${error.message}`);
422
+ process.exit(2);
423
+ }
424
+ break;
425
+ }
426
+ case 'control': {
427
+ requireLedger();
428
+ const options = flags(rest);
429
+ try {
430
+ const tasks = listControl(DIR);
431
+ if (options.json) {
432
+ console.log(JSON.stringify(tasks));
433
+ break;
434
+ }
435
+ if (tasks.length === 0) {
436
+ console.log('no paused or stopped tasks');
437
+ break;
438
+ }
439
+ for (const item of tasks) {
440
+ console.log(`${item.task}\t${item.mode}\t${item.at}${item.reason ? `\t${item.reason}` : ''}`);
441
+ }
442
+ } catch (error) {
443
+ console.error(`control: ${error.message}`);
444
+ process.exit(2);
445
+ }
446
+ break;
447
+ }
448
+ case 'prove':
449
+ prove(rest);
450
+ break;
451
+ case 'verify-receipt':
452
+ verifyReceiptCommand(rest);
453
+ break;
454
+ case 'export': {
455
+ requireLedger();
456
+ const options = flags(rest);
457
+ try {
458
+ writeJsonOut(DIR, bundleFor(DIR, rest[0]), options.out);
459
+ } catch (error) {
460
+ console.error(error.message);
461
+ process.exit(1);
462
+ }
463
+ break;
464
+ }
465
+ case 'verify-bundle': {
466
+ const options = flags(rest);
467
+ const file = rest[0];
468
+ if (!file) {
469
+ console.error('attesso verify-bundle <file>');
470
+ process.exit(2);
471
+ }
472
+ const bundle = readJsonFile(file, 'verify-bundle');
473
+ const result = verifyBundle(bundle, {
474
+ replay: options.replay === true,
475
+ publicPem: options.key ? publicKey(options.key) : undefined,
476
+ });
477
+ if (!result.ok) {
478
+ console.error(`bundle does not verify: ${result.reason}`);
479
+ process.exit(1);
480
+ }
481
+ const note = result.historical ? ' (historical entry: no rules digest)' : '';
482
+ console.log(`bundle verifies: ${result.reason}${note}`);
483
+ break;
484
+ }
223
485
  case 'show':
224
486
  show();
225
487
  break;
488
+ case 'watch': {
489
+ requireLedger();
490
+ const options = flags(rest);
491
+ const raw = options['from-seq'];
492
+ const fromSeq = raw === undefined ? Number.MAX_SAFE_INTEGER : Number(raw);
493
+ if (!Number.isInteger(fromSeq) || fromSeq < 0) {
494
+ console.error('--from-seq takes a whole sequence number');
495
+ process.exit(2);
496
+ }
497
+ if (options.task !== undefined && typeof options.task !== 'string') {
498
+ console.error('--task takes a task id');
499
+ process.exit(2);
500
+ }
501
+ const controller = new AbortController();
502
+ const stop = () => controller.abort();
503
+ process.once('SIGINT', stop);
504
+ try {
505
+ for await (const entry of watchEntries(DIR, {
506
+ fromSeq,
507
+ task: options.task,
508
+ signal: controller.signal,
509
+ })) {
510
+ console.log(options.json ? JSON.stringify(entry) : renderWatchEntry(entry));
511
+ }
512
+ } catch (error) {
513
+ console.error(`watch: ${error.message}`);
514
+ process.exitCode = 1;
515
+ } finally {
516
+ process.removeListener('SIGINT', stop);
517
+ }
518
+ break;
519
+ }
520
+ case 'connect': {
521
+ try {
522
+ const options = flags(rest);
523
+ const mode = typeof options.mode === 'string'
524
+ ? options.mode
525
+ : options.off ? 'off' : options.enforce ? 'enforce' : options.observe ? 'observe' : 'observe';
526
+ const result = connectAgent({
527
+ dir: DIR,
528
+ cwd: process.cwd(),
529
+ cli: resolve(process.argv[1]),
530
+ write: rest.includes('--write'),
531
+ mode,
532
+ harness: typeof options.harness === 'string' ? options.harness : null,
533
+ });
534
+ if (options.json) {
535
+ console.log(JSON.stringify(result, null, 2));
536
+ } else {
537
+ console.log(`mode: ${result.mode ?? 'unset'}`);
538
+ console.log(`ledger: ${result.initialized ? result.ledger : 'not initialized'}`);
539
+ for (const harness of result.harnesses) {
540
+ const state = harness.applied ? 'wired' : harness.dryRun ? 'would wire' : harness.wired || harness.reason === 'already wired' ? 'already wired' : harness.error || harness.reason || 'manual';
541
+ console.log(`${harness.key}: ${state}`);
542
+ for (const warning of harness.warnings ?? []) console.log(` note: ${warning}`);
543
+ }
544
+ if (result.manual.length) console.log(`manual: ${result.manual.join(', ')}`);
545
+ for (const line of result.next) console.log(`next: ${line}`);
546
+ }
547
+ if (!result.initialized && rest.includes('--write')) process.exitCode = 1;
548
+ } catch (error) {
549
+ console.error(`connect: ${error.message}`);
550
+ process.exit(2);
551
+ }
552
+ break;
553
+ }
554
+ case 'status': {
555
+ try {
556
+ const options = flags(rest);
557
+ const result = agentStatus({ dir: DIR, cwd: process.cwd() });
558
+ if (options.json) {
559
+ console.log(JSON.stringify(result, null, 2));
560
+ } else {
561
+ console.log(`connected: ${result.connected ? 'yes' : 'no'}`);
562
+ console.log(`mode: ${result.mode ?? 'unset'}`);
563
+ console.log(`ledger: ${result.ledger ?? 'not initialized'}`);
564
+ for (const harness of result.harnesses) console.log(`${harness.key}: ${harness.wired ? 'wired' : 'not wired'}`);
565
+ }
566
+ } catch (error) {
567
+ console.error(`status: ${error.message}`);
568
+ process.exit(2);
569
+ }
570
+ break;
571
+ }
572
+ case 'mode': {
573
+ try {
574
+ const options = flags(rest);
575
+ const desired = typeof options.mode === 'string' ? options.mode : rest.find((value) => !value.startsWith('--'));
576
+ if (desired === undefined) {
577
+ const current = readMode(DIR);
578
+ if (options.json) console.log(JSON.stringify({ mode: current }));
579
+ else console.log(`mode: ${current ?? 'unset'}`);
580
+ break;
581
+ }
582
+ if (!CONNECT_MODES.includes(desired)) {
583
+ console.error(`mode takes ${CONNECT_MODES.join(', ')}`);
584
+ process.exit(2);
585
+ }
586
+ const result = setModeAll(DIR, desired);
587
+ if (options.json) console.log(JSON.stringify({ mode: result }));
588
+ else console.log(`mode: ${result}`);
589
+ } catch (error) {
590
+ console.error(`mode: ${error.message}`);
591
+ process.exit(2);
592
+ }
593
+ break;
594
+ }
595
+ case 'paths': {
596
+ try {
597
+ const options = flags(rest);
598
+ const file = paths(DIR).fsPaths;
599
+ if (options.set) {
600
+ const value = validatePathsConfig(readJsonFile(options.set, 'paths'));
601
+ mkdirSync(DIR, { recursive: true });
602
+ const temp = `${file}.${process.pid}.tmp`;
603
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
604
+ renameSync(temp, file);
605
+ if (options.json) console.log(JSON.stringify({ ok: true, paths: value }, null, 2));
606
+ else console.log(`paths: ${value.allowed_roots.length} roots, ${value.exclusions.length} exclusions`);
607
+ break;
608
+ }
609
+ if (!existsSync(file)) {
610
+ if (options.json) console.log(JSON.stringify({ configured: false, paths: null }));
611
+ else console.log('paths: not configured');
612
+ break;
613
+ }
614
+ const value = validatePathsConfig(readJsonFile(file, 'paths'));
615
+ if (options.json) console.log(JSON.stringify({ configured: true, paths: value }, null, 2));
616
+ else console.log(`paths: ${value.allowed_roots.length} roots, ${value.exclusions.length} exclusions`);
617
+ } catch (error) {
618
+ console.error(`paths: ${error.message}`);
619
+ process.exit(2);
620
+ }
621
+ break;
622
+ }
623
+ case 'ui': {
624
+ try {
625
+ const options = flags(rest);
626
+ const requested = options.port === undefined ? 0 : Number(options.port);
627
+ if (!Number.isInteger(requested) || requested < 0 || requested > 65535) {
628
+ console.error('ui: --port must be an integer from 0 to 65535');
629
+ process.exit(2);
630
+ }
631
+ const ui = await startUi({
632
+ dir: DIR,
633
+ cwd: process.cwd(),
634
+ port: requested,
635
+ open: !rest.includes('--no-open'),
636
+ });
637
+ console.log(`bounds ui: ${ui.url}`);
638
+ console.log('press Ctrl-C to stop');
639
+ const stop = () => { ui.close().then(() => process.exit(0), () => process.exit(1)); };
640
+ process.once('SIGINT', stop);
641
+ process.once('SIGTERM', stop);
642
+ } catch (error) {
643
+ console.error(`ui: ${error.message}`);
644
+ process.exit(2);
645
+ }
646
+ break;
647
+ }
648
+ case 'setup': {
649
+ const dryRun = rest.includes('--dry-run');
650
+ const write = rest.includes('--write');
651
+ const enforce = rest.includes('--enforce');
652
+ const harnesses = detectHarnesses();
653
+ const cli = resolve(process.argv[1]);
654
+ const ledgerDir = resolve(DIR);
655
+ const agent = writeAgentContext(process.cwd(), { dryRun });
656
+ console.log(`agent context: ${agent.changed ? (dryRun ? 'would update' : 'updated') : 'already current'} ${agent.path}`);
657
+ if (harnesses.length === 0) {
658
+ console.log('no harness config found in this directory or ~/.hermes');
659
+ console.log('wire any harness with this contract: run `attesso hook` as a pre_tool_call hook; exit 2 refuses.');
660
+ }
661
+ for (const harness of harnesses) {
662
+ if (!HARNESSES[harness.key]) {
663
+ console.log(`${harness.key}: found ${harness.path}; wire it with \`${cli} --dir ${ledgerDir} hook\` as its pre-tool hook`);
664
+ continue;
665
+ }
666
+ if (harness.key !== 'hermes' && !write) {
667
+ console.log(`${harness.key}: found ${harness.path}; run setup --write to install the ${enforce ? 'enforce' : 'observe'} hook`);
668
+ continue;
669
+ }
670
+ try {
671
+ const result = wireHarness(harness.key, cli, ledgerDir, {
672
+ dryRun,
673
+ enforce,
674
+ path: harness.path,
675
+ });
676
+ const state = result.applied ? 'wired' : result.dryRun ? 'would wire' : result.reason;
677
+ console.log(`${harness.key}: ${state}`);
678
+ if (result.snippet && !result.applied) console.log(result.snippet);
679
+ } catch (error) {
680
+ console.error(`${harness.key}: ${error.message}`);
681
+ }
682
+ }
683
+ break;
684
+ }
685
+ case 'explain': {
686
+ requireLedger();
687
+ const seq = rest[0];
688
+ if (!seq) {
689
+ console.error('attesso explain <seq>');
690
+ process.exit(1);
691
+ }
692
+ const detail = explain(DIR, seq, readState(DIR));
693
+ if (!detail.found) {
694
+ console.error(`no entry #${seq} in this ledger`);
695
+ process.exit(1);
696
+ }
697
+ console.log(JSON.stringify(detail, null, 2));
698
+ break;
699
+ }
700
+ case 'suggest': {
701
+ requireLedger();
702
+ const proposal = suggest(DIR, readState(DIR));
703
+ if (rest.includes('--json')) {
704
+ console.log(JSON.stringify(proposal, null, 2));
705
+ } else if (proposal.repeats.length === 0) {
706
+ console.log('no repeats yet: nothing the ledger can justify narrowing');
707
+ } else {
708
+ for (const repeat of proposal.repeats) {
709
+ console.log(`${repeat.id}: ${repeat.why}`);
710
+ console.log(` ${JSON.stringify(repeat.rule)}`);
711
+ }
712
+ }
713
+ break;
714
+ }
715
+ case 'tools':
716
+ toolsCommand(rest);
717
+ break;
226
718
  case 'hook': {
719
+ let configuredMode;
720
+ try {
721
+ configuredMode = readMode(DIR);
722
+ } catch (error) {
723
+ console.error(`attesso hook: ${error.message}`);
724
+ process.exit(2);
725
+ }
726
+ if (configuredMode === 'off') {
727
+ process.exitCode = 0;
728
+ break;
729
+ }
227
730
  requireLedger();
228
- const run = decide({ append, dir: DIR, observe: rest.includes('--observe') });
731
+ const observe = configuredMode === null ? rest.includes('--observe') : configuredMode === 'observe';
732
+ const run = createHookDecider({ append, dir: DIR, observe });
229
733
  let raw = '';
230
734
  process.stdin.setEncoding('utf8');
231
735
  process.stdin.on('data', (chunk) => {
@@ -237,24 +741,55 @@ switch (command) {
237
741
  payload = JSON.parse(raw || '{}');
238
742
  } catch {
239
743
  console.error('attesso hook: stdin was not JSON, so no decision was made');
240
- process.exit(1);
744
+ process.exit(observe ? 0 : 2);
241
745
  }
746
+ let normalized;
242
747
  try {
243
- const result = run(payload);
244
- if (result.exitCode !== 0) {
245
- process.stdout.write(`${JSON.stringify({ decision: 'block', message: result.body.message, code: result.body.code })}\n`);
246
- process.exit(2);
247
- }
248
- process.stdout.write(`${JSON.stringify({ decision: result.body.decision, code: result.body.code })}\n`);
748
+ normalized = normalizeHookPayload(payload);
749
+ const result = run(normalized.payload);
750
+ const rendered = renderHook({ harness: normalized.harness, result });
751
+ if (rendered.stdout) process.stdout.write(rendered.stdout);
752
+ process.exit(rendered.exitCode);
249
753
  } catch (error) {
250
- // An unwritable ledger is an outage, not a policy decision: say so and
251
- // exit 1 so a fail-closed harness can tell the two apart.
754
+ // An unwritable ledger is not a policy statement, but enforcement must
755
+ // fail closed. Observe mode stays out of the way.
756
+ const body = {
757
+ decision: 'DENY',
758
+ code: 'LEDGER_UNAVAILABLE',
759
+ message: `Attesso could not read the ledger: ${error.message}`,
760
+ enforced: !observe,
761
+ };
762
+ const rendered = renderHook({ harness: normalized?.harness ?? 'generic', result: { body } });
763
+ if (rendered.stdout) process.stdout.write(rendered.stdout);
252
764
  console.error(`attesso hook: ${error.message}`);
253
- process.exit(1);
765
+ process.exit(observe ? 0 : rendered.exitCode);
254
766
  }
255
767
  });
256
768
  break;
257
769
  }
770
+ case 'replay': {
771
+ requireLedger();
772
+ const options = flags(rest);
773
+ let candidatePolicy;
774
+ let candidateRules;
775
+ try {
776
+ candidatePolicy = validatePolicy(JSON.parse(readFileSync(options.policy ?? paths(DIR).policy, 'utf8')));
777
+ candidateRules = validateRules(JSON.parse(readFileSync(options.rules ?? paths(DIR).rules, 'utf8')));
778
+ } catch (error) {
779
+ console.error(`replay: ${error.message}`);
780
+ process.exit(2);
781
+ }
782
+ const summary = replay(readState(DIR).entries, { policy: candidatePolicy, rules: candidateRules });
783
+ if (options.json) {
784
+ console.log(JSON.stringify(summary, null, 2));
785
+ } else {
786
+ process.stdout.write(renderReplay(summary));
787
+ }
788
+ if (options['max-newly-denied'] !== undefined && summary.newly_denied > Number(options['max-newly-denied'])) {
789
+ process.exitCode = 1;
790
+ }
791
+ break;
792
+ }
258
793
  case 'serve':
259
794
  requireLedger();
260
795
  serve(DIR, { observe: rest.includes('--observe') }).catch((error) => {
@@ -264,7 +799,7 @@ switch (command) {
264
799
  break;
265
800
  case 'report': {
266
801
  requireLedger();
267
- const report = buildReport(DIR);
802
+ const report = await buildReportStream(DIR);
268
803
  if (rest.includes('--json')) {
269
804
  console.log(JSON.stringify(report, null, 2));
270
805
  } else {