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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +172 -19
- package/bin/attesso-control-mcp.mjs +13 -0
- package/bin/attesso-mcp.mjs +2 -0
- package/bin/attesso.mjs +600 -65
- package/package.json +8 -4
- package/src/bundle.mjs +128 -0
- package/src/classify.mjs +83 -0
- package/src/cli-io.mjs +130 -0
- package/src/connect.mjs +160 -0
- package/src/control.mjs +101 -0
- package/src/decide.mjs +10 -0
- package/src/fs-paths.mjs +121 -0
- package/src/harness.mjs +130 -0
- package/src/hook.mjs +72 -37
- package/src/install.mjs +122 -0
- package/src/jsonl.mjs +16 -0
- package/src/ledger.mjs +79 -20
- package/src/mcp/control.mjs +171 -0
- package/src/mcp-proxy.mjs +605 -0
- package/src/merkle.mjs +83 -0
- package/src/policy.mjs +8 -8
- package/src/receipt.mjs +104 -0
- package/src/replay.mjs +133 -0
- package/src/report.mjs +69 -27
- package/src/rules.mjs +78 -6
- package/src/serve.mjs +4 -2
- package/src/setup.mjs +131 -0
- package/src/store.mjs +425 -64
- package/src/stream.mjs +72 -0
- package/src/toolpin.mjs +131 -0
- package/src/ui/catalog.mjs +266 -0
- package/src/ui/live.mjs +58 -0
- package/src/ui/query.mjs +163 -0
- package/src/ui/server.mjs +379 -0
- package/src/ui/static/index.html +1695 -0
- package/src/usage.mjs +96 -0
- package/src/watch.mjs +78 -0
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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 {
|
|
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]
|
|
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:
|
|
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 && !
|
|
121
|
-
console.error(
|
|
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 && !
|
|
125
|
-
console.error(
|
|
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.
|
|
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
|
|
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(`
|
|
289
|
+
console.error(`receipt does not verify: ${result.reason}`);
|
|
152
290
|
process.exit(1);
|
|
153
291
|
}
|
|
154
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
console.log(
|
|
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
|
|
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(
|
|
744
|
+
process.exit(observe ? 0 : 2);
|
|
241
745
|
}
|
|
746
|
+
let normalized;
|
|
242
747
|
try {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
251
|
-
//
|
|
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(
|
|
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 =
|
|
802
|
+
const report = await buildReportStream(DIR);
|
|
268
803
|
if (rest.includes('--json')) {
|
|
269
804
|
console.log(JSON.stringify(report, null, 2));
|
|
270
805
|
} else {
|