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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +158 -20
- package/bin/attesso-control-mcp.mjs +13 -0
- package/bin/attesso-mcp.mjs +2 -0
- package/bin/attesso.mjs +554 -75
- 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 +64 -40
- 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 +58 -11
- package/src/serve.mjs +4 -2
- package/src/setup.mjs +34 -45
- 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,12 +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 {
|
|
15
|
-
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';
|
|
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]
|
|
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:
|
|
35
|
-
attesso setup [--dry-run]
|
|
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 && !
|
|
125
|
-
console.error(
|
|
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 && !
|
|
129
|
-
console.error(
|
|
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.
|
|
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
|
|
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(`
|
|
289
|
+
console.error(`receipt does not verify: ${result.reason}`);
|
|
156
290
|
process.exit(1);
|
|
157
291
|
}
|
|
158
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
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`);
|
|
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
|
|
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
|
|
242
|
-
console.log(`${harness.key}: found ${harness.path};
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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(
|
|
744
|
+
process.exit(observe ? 0 : 2);
|
|
297
745
|
}
|
|
746
|
+
let normalized;
|
|
298
747
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
307
|
-
//
|
|
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(
|
|
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 =
|
|
802
|
+
const report = await buildReportStream(DIR);
|
|
324
803
|
if (rest.includes('--json')) {
|
|
325
804
|
console.log(JSON.stringify(report, null, 2));
|
|
326
805
|
} else {
|