attesso 0.4.1 → 0.6.1

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,12 +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';
15
- import { detectHarnesses, explain, suggest, wireHermes, writeAgentContext } from './../src/setup.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';
16
32
 
17
33
  const argv = process.argv.slice(2);
18
34
  const dirAt = argv.indexOf('--dir');
@@ -21,20 +37,50 @@ if (dirAt !== -1) argv.splice(dirAt, 2);
21
37
 
22
38
  const USAGE = `attesso: a local, signed ledger of what your agents attempted
23
39
 
24
- 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
25
42
  attesso log --action A [--subject S] [--amount 12.50 --currency EUR]
26
- [--task T] [--trust internal|untrusted] [--capability read|send|execute|spend]
43
+ [--task T] [--trust internal|untrusted] [--capability read|write|send|execute|spend]
27
44
  [--meta k=v ...] [--json]
28
45
  evaluate one proposed action against the policy
29
46
  and the path of its task, then record it
30
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
31
64
  attesso show what was allowed, what was refused, and why
32
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
33
69
  attesso serve [--observe] long-lived: one JSON request per line in, one verdict out
34
- attesso hook [--observe] harness hook: reads one tool call on stdin, exits 2 to refuse
35
- attesso setup [--dry-run] detect a harness, wire the hook, write the agent block
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
36
79
  attesso explain <seq> one entry in full: rule, tainting step, what to do
37
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
38
84
 
39
85
  Any action can be logged, not only payments. --subject is the thing acted on (a
40
86
  tool name, a payee, a repo). --task scopes the path, --trust marks where the
@@ -47,39 +93,6 @@ The log is signed with a key that lives in .attesso/key.json. That proves the
47
93
  ledger was not edited; it does not prove a human authorized anything. Hosted
48
94
  Attesso adds passkey approval and evidence a third party verifies offline.`;
49
95
 
50
- function flags(argv) {
51
- const out = {};
52
- for (let index = 0; index < argv.length; index += 1) {
53
- const token = argv[index];
54
- if (!token.startsWith('--')) continue;
55
- const name = token.slice(2);
56
- const next = argv[index + 1];
57
- if (next === undefined || next.startsWith('--')) {
58
- out[name] = true;
59
- continue;
60
- }
61
- if (name === 'meta') {
62
- out.meta = [...(out.meta ?? []), next];
63
- } else {
64
- out[name] = next;
65
- }
66
- index += 1;
67
- }
68
- return out;
69
- }
70
-
71
- function parseMeta(pairs = []) {
72
- const meta = {};
73
- for (const pair of pairs) {
74
- const at = pair.indexOf('=');
75
- if (at <= 0) throw new Error(`--meta expects key=value, got ${pair}`);
76
- const key = pair.slice(0, at);
77
- const raw = pair.slice(at + 1);
78
- meta[key] = raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
79
- }
80
- return meta;
81
- }
82
-
83
96
  function requireLedger() {
84
97
  if (!isInitialised(DIR)) {
85
98
  console.error(`no ledger at ${paths(DIR).dir}; run attesso init first`);
@@ -93,6 +106,54 @@ function describe(entry) {
93
106
  return `${entry.action}${subject}${amount}`;
94
107
  }
95
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
+
96
157
  function log(argv) {
97
158
  requireLedger();
98
159
  const options = flags(argv);
@@ -121,12 +182,12 @@ function log(argv) {
121
182
  process.exit(2);
122
183
  }
123
184
 
124
- if (options.trust !== undefined && !['internal', 'untrusted', 'unknown'].includes(options.trust)) {
125
- 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(', ')}`);
126
187
  process.exit(2);
127
188
  }
128
- if (options.capability !== undefined && !['read', 'send', 'execute', 'spend'].includes(options.capability)) {
129
- 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(', ')}`);
130
191
  process.exit(2);
131
192
  }
132
193
  const entry = append(DIR, {
@@ -142,20 +203,94 @@ function log(argv) {
142
203
  console.log(JSON.stringify(entry));
143
204
  return;
144
205
  }
145
- 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}`;
146
207
  console.log(`#${entry.seq} ${entry.decision} ${describe(entry)} (${entry.code}${taint})`);
147
208
  process.exitCode = entry.decision === 'ALLOW' ? 0 : 1;
148
209
  }
149
210
 
150
- function verify() {
211
+ function verify(rest = []) {
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) {
151
260
  requireLedger();
261
+ const options = flags(rest);
262
+ const seq = Number(rest[0]);
152
263
  const state = readState(DIR);
153
- 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);
154
288
  if (!result.ok) {
155
- console.error(`ledger does not verify: ${result.reason}`);
289
+ console.error(`receipt does not verify: ${result.reason}`);
156
290
  process.exit(1);
157
291
  }
158
- 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}`);
159
294
  }
160
295
 
161
296
  function totals(entries) {
@@ -206,12 +341,20 @@ function show() {
206
341
  }
207
342
 
208
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
+
209
351
  switch (command) {
210
352
  case 'init': {
211
353
  try {
212
- const result = init(DIR);
213
- console.log(`created ${result.dir} with key ${result.kid}`);
214
- 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`);
215
358
  } catch (error) {
216
359
  console.error(error.message);
217
360
  process.exit(1);
@@ -222,30 +365,320 @@ switch (command) {
222
365
  log(rest);
223
366
  break;
224
367
  case 'verify':
225
- 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);
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);
226
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
+ }
227
485
  case 'show':
228
486
  show();
229
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
+ }
230
648
  case 'setup': {
231
649
  const dryRun = rest.includes('--dry-run');
650
+ const write = rest.includes('--write');
651
+ const enforce = rest.includes('--enforce');
232
652
  const harnesses = detectHarnesses();
233
- const cli = process.argv[1];
234
- const agent = writeAgentContext(DIR, { dryRun });
653
+ const cli = resolve(process.argv[1]);
654
+ const ledgerDir = resolve(DIR);
655
+ const agent = writeAgentContext(process.cwd(), { dryRun });
235
656
  console.log(`agent context: ${agent.changed ? (dryRun ? 'would update' : 'updated') : 'already current'} ${agent.path}`);
236
657
  if (harnesses.length === 0) {
237
658
  console.log('no harness config found in this directory or ~/.hermes');
238
659
  console.log('wire any harness with this contract: run `attesso hook` as a pre_tool_call hook; exit 2 refuses.');
239
660
  }
240
661
  for (const harness of harnesses) {
241
- if (harness.key !== 'hermes') {
242
- console.log(`${harness.key}: found ${harness.path}; snippet for its pre_tool_call hook:`);
243
- console.log(` ${cli} --dir ${DIR} hook --observe`);
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`);
244
668
  continue;
245
669
  }
246
- const result = wireHermes(cli, DIR, { dryRun, path: harness.path });
247
- console.log(`hermes: ${result.applied ? (dryRun ? 'would wire' : 'wired') : result.reason}`);
248
- if (result.snippet && !result.applied) console.log(result.snippet);
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
+ }
249
682
  }
250
683
  break;
251
684
  }
@@ -279,9 +712,24 @@ switch (command) {
279
712
  }
280
713
  break;
281
714
  }
715
+ case 'tools':
716
+ toolsCommand(rest);
717
+ break;
282
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
+ }
283
730
  requireLedger();
284
- 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 });
285
733
  let raw = '';
286
734
  process.stdin.setEncoding('utf8');
287
735
  process.stdin.on('data', (chunk) => {
@@ -293,24 +741,55 @@ switch (command) {
293
741
  payload = JSON.parse(raw || '{}');
294
742
  } catch {
295
743
  console.error('attesso hook: stdin was not JSON, so no decision was made');
296
- process.exit(1);
744
+ process.exit(observe ? 0 : 2);
297
745
  }
746
+ let normalized;
298
747
  try {
299
- const result = run(payload);
300
- if (result.exitCode !== 0) {
301
- process.stdout.write(`${JSON.stringify({ decision: 'block', message: result.body.message, code: result.body.code })}\n`);
302
- process.exit(2);
303
- }
304
- 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);
305
753
  } catch (error) {
306
- // An unwritable ledger is an outage, not a policy decision: say so and
307
- // 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);
308
764
  console.error(`attesso hook: ${error.message}`);
309
- process.exit(1);
765
+ process.exit(observe ? 0 : rendered.exitCode);
310
766
  }
311
767
  });
312
768
  break;
313
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
+ }
314
793
  case 'serve':
315
794
  requireLedger();
316
795
  serve(DIR, { observe: rest.includes('--observe') }).catch((error) => {
@@ -320,7 +799,7 @@ switch (command) {
320
799
  break;
321
800
  case 'report': {
322
801
  requireLedger();
323
- const report = buildReport(DIR);
802
+ const report = await buildReportStream(DIR);
324
803
  if (rest.includes('--json')) {
325
804
  console.log(JSON.stringify(report, null, 2));
326
805
  } else {