attesso 0.4.1 → 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 +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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Local Bounds control surface. One loopback process, one per-run token, one
|
|
2
|
+
// ledger. The page is static; all state comes from the same core functions the
|
|
3
|
+
// CLI uses. No hosted code and no second policy implementation.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { gzipSync } from 'node:zlib';
|
|
12
|
+
import { connect as connectAgent, setModeAll, status as agentStatus } from '../connect.mjs';
|
|
13
|
+
import { validatePathsConfig } from '../fs-paths.mjs';
|
|
14
|
+
import { isInitialised, paths, readEntries } from '../store.mjs';
|
|
15
|
+
import { Catalog, validateTimeZone } from './catalog.mjs';
|
|
16
|
+
import { usageForDay } from '../usage.mjs';
|
|
17
|
+
import { days as queryDays, flow as queryFlow, page as queryPage, parseCursor, parseDay, parseLimit, summary as querySummary } from './query.mjs';
|
|
18
|
+
import { LedgerTail } from './live.mjs';
|
|
19
|
+
|
|
20
|
+
const UI_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const STATIC_ROOT = join(UI_ROOT, 'static');
|
|
22
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
|
|
23
|
+
const MAX_BODY = 512_000;
|
|
24
|
+
|
|
25
|
+
const localDay = (date) => {
|
|
26
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
27
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
28
|
+
};
|
|
29
|
+
const localTime = (date) => {
|
|
30
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
31
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function rowFor(entry) {
|
|
35
|
+
const date = new Date(entry.at);
|
|
36
|
+
return [
|
|
37
|
+
entry.seq,
|
|
38
|
+
localTime(date),
|
|
39
|
+
entry.subject ?? 'unknown',
|
|
40
|
+
entry.capability ?? 'unknown',
|
|
41
|
+
entry.decision ?? 'UNKNOWN',
|
|
42
|
+
entry.code ?? '',
|
|
43
|
+
entry.task ?? 'unknown',
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function callsPayload(dir) {
|
|
48
|
+
if (!isInitialised(dir)) return { days: {}, head: 0 };
|
|
49
|
+
const days = {};
|
|
50
|
+
let head = 0;
|
|
51
|
+
for (const entry of readEntries(dir)) {
|
|
52
|
+
head = Math.max(head, entry.seq);
|
|
53
|
+
if (entry.action === 'llm.session') continue;
|
|
54
|
+
const day = localDay(new Date(entry.at));
|
|
55
|
+
if (!days[day]) days[day] = [];
|
|
56
|
+
days[day].push(rowFor(entry));
|
|
57
|
+
}
|
|
58
|
+
return { days, head };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const readJson = (file, fallback) => {
|
|
62
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
63
|
+
};
|
|
64
|
+
const writeJson = (file, value) => {
|
|
65
|
+
const temp = `${file}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
|
|
66
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
67
|
+
renameSync(temp, file);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function send(req, res, status, type, body) {
|
|
71
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
72
|
+
const headers = { 'content-type': type, 'cache-control': 'no-store' };
|
|
73
|
+
if (buffer.length > 1024 && String(req.headers['accept-encoding'] ?? '').includes('gzip')) {
|
|
74
|
+
const compressed = gzipSync(buffer, { level: 6 });
|
|
75
|
+
headers['content-encoding'] = 'gzip';
|
|
76
|
+
headers.vary = 'accept-encoding';
|
|
77
|
+
res.writeHead(status, headers);
|
|
78
|
+
return res.end(compressed);
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(status, headers);
|
|
81
|
+
res.end(buffer);
|
|
82
|
+
}
|
|
83
|
+
function json(res, status, body) {
|
|
84
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
85
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function queryInt(url, name, min, max) {
|
|
89
|
+
const value = url.searchParams.get(name);
|
|
90
|
+
if (value === null || value === '') return undefined;
|
|
91
|
+
const number = Number(value);
|
|
92
|
+
if (!Number.isInteger(number) || number < min || number > max) throw new Error(`${name} must be an integer from ${min} to ${max}`);
|
|
93
|
+
return number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function queryString(url, name, max = 200) {
|
|
97
|
+
const value = url.searchParams.get(name);
|
|
98
|
+
if (value === null || value === '') return undefined;
|
|
99
|
+
if (value.length > max) throw new Error(`${name} is too long`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function callFilters(url) {
|
|
104
|
+
return {
|
|
105
|
+
minute: queryInt(url, 'minute', 0, 1435),
|
|
106
|
+
hour: queryInt(url, 'hour', 0, 23),
|
|
107
|
+
tool: queryString(url, 'tool', 160),
|
|
108
|
+
capability: queryString(url, 'capability', 32),
|
|
109
|
+
decision: queryString(url, 'decision', 16),
|
|
110
|
+
search: queryString(url, 'search', 200)?.toLowerCase(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function body(req) {
|
|
115
|
+
return new Promise((resolveBody, reject) => {
|
|
116
|
+
let raw = '';
|
|
117
|
+
req.on('data', (chunk) => {
|
|
118
|
+
raw += chunk;
|
|
119
|
+
if (raw.length > MAX_BODY) {
|
|
120
|
+
req.destroy();
|
|
121
|
+
reject(new Error('request body too large'));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
req.on('end', () => {
|
|
125
|
+
try { resolveBody(raw ? JSON.parse(raw) : {}); } catch (error) { reject(error); }
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readPaths(dir) {
|
|
132
|
+
const value = readJson(paths(dir).fsPaths, null);
|
|
133
|
+
if (value === null) return { version: 1, allowed_roots: [], exclusions: [] };
|
|
134
|
+
return validatePathsConfig(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function staticFile(name) {
|
|
138
|
+
const file = resolve(STATIC_ROOT, normalize(name));
|
|
139
|
+
if (!file.startsWith(STATIC_ROOT + sep)) return null;
|
|
140
|
+
if (!['index.html', 'app.js', 'app.css'].includes(name.split('/').pop())) return null;
|
|
141
|
+
if (!existsSync(file)) return null;
|
|
142
|
+
return { file, type: MIME[extname(file)] ?? 'application/octet-stream' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function startUi({
|
|
146
|
+
dir = '.attesso',
|
|
147
|
+
cwd = process.cwd(),
|
|
148
|
+
home = homedir(),
|
|
149
|
+
port = 0,
|
|
150
|
+
open = true,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const ledger = resolve(dir);
|
|
153
|
+
const catalog = new Catalog(ledger);
|
|
154
|
+
const token = randomBytes(24).toString('hex');
|
|
155
|
+
const clients = new Set();
|
|
156
|
+
const tail = new LedgerTail(paths(ledger).ledger);
|
|
157
|
+
let tailTimer = null;
|
|
158
|
+
|
|
159
|
+
const authorized = (req) => {
|
|
160
|
+
const value = String(req.headers['x-bounds-token'] ?? '');
|
|
161
|
+
if (value.length !== token.length) return false;
|
|
162
|
+
return timingSafeEqual(Buffer.from(value), Buffer.from(token));
|
|
163
|
+
};
|
|
164
|
+
const sameOrigin = (req, address) => {
|
|
165
|
+
const origin = req.headers.origin;
|
|
166
|
+
if (!origin) return true;
|
|
167
|
+
const allowed = [`http://127.0.0.1:${address.port}`, `http://localhost:${address.port}`];
|
|
168
|
+
return allowed.includes(origin);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const broadcast = (entry) => {
|
|
172
|
+
const day = localDay(new Date(entry.at));
|
|
173
|
+
const message = `id: ${entry.seq}\nevent: decision\ndata: ${JSON.stringify({ day, row: rowFor(entry), seq: entry.seq })}\n\n`;
|
|
174
|
+
for (const client of clients) {
|
|
175
|
+
try {
|
|
176
|
+
if (client.res.writableLength > 1_000_000) {
|
|
177
|
+
client.res.end();
|
|
178
|
+
clients.delete(client);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
client.res.write(message);
|
|
182
|
+
} catch {
|
|
183
|
+
clients.delete(client);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const poll = () => {
|
|
189
|
+
if (clients.size === 0) return;
|
|
190
|
+
try {
|
|
191
|
+
tail.read((line) => {
|
|
192
|
+
try {
|
|
193
|
+
const entry = JSON.parse(line);
|
|
194
|
+
if (entry?.seq && entry.action !== 'llm.session') broadcast(entry);
|
|
195
|
+
} catch {
|
|
196
|
+
// A malformed complete line is a ledger error; do not invent an event.
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
// The ledger can be replaced while the UI is open. The next poll reopens it.
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const server = createServer(async (req, res) => {
|
|
205
|
+
const host = (req.headers.host ?? '').split(':')[0];
|
|
206
|
+
if (host !== '127.0.0.1' && host !== 'localhost') return json(res, 403, { error: 'loopback only' });
|
|
207
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
208
|
+
const address = server.address();
|
|
209
|
+
try {
|
|
210
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
211
|
+
const page = readFileSync(join(STATIC_ROOT, 'index.html'), 'utf8').replace('__BOUNDS_TOKEN__', token);
|
|
212
|
+
return send(req, res, 200, MIME['.html'], page);
|
|
213
|
+
}
|
|
214
|
+
if (!url.pathname.startsWith('/api/')) {
|
|
215
|
+
const file = staticFile(url.pathname.slice(1));
|
|
216
|
+
if (!file) return json(res, 404, { error: 'not found' });
|
|
217
|
+
return send(req, res, 200, file.type, readFileSync(file.file));
|
|
218
|
+
}
|
|
219
|
+
if (!authorized(req)) return json(res, 401, { error: 'missing bounds token' });
|
|
220
|
+
if (!sameOrigin(req, address)) return json(res, 403, { error: 'origin refused' });
|
|
221
|
+
|
|
222
|
+
if (url.pathname === '/api/status' && req.method === 'GET') {
|
|
223
|
+
let pathsValue;
|
|
224
|
+
try { pathsValue = readPaths(ledger); } catch (error) { pathsValue = null; }
|
|
225
|
+
return json(res, 200, { ...agentStatus({ dir: ledger, cwd, home }), paths: pathsValue });
|
|
226
|
+
}
|
|
227
|
+
if (url.pathname === '/api/calls' && req.method === 'GET') {
|
|
228
|
+
return json(res, 200, callsPayload(ledger));
|
|
229
|
+
}
|
|
230
|
+
if (url.pathname.startsWith('/api/v1/') && req.method === 'GET') {
|
|
231
|
+
try {
|
|
232
|
+
const timeZone = validateTimeZone(url.searchParams.get('tz') ?? undefined);
|
|
233
|
+
if (url.pathname === '/api/v1/summary') {
|
|
234
|
+
return json(res, 200, querySummary(catalog, { day: parseDay(url.searchParams.get('day'), timeZone), timeZone }));
|
|
235
|
+
}
|
|
236
|
+
if (url.pathname === '/api/v1/flow') {
|
|
237
|
+
return json(res, 200, queryFlow(catalog, {
|
|
238
|
+
day: parseDay(url.searchParams.get('day'), timeZone),
|
|
239
|
+
timeZone,
|
|
240
|
+
bucket: parseLimit(url.searchParams.get('bucket'), { fallback: 5, max: 60 }),
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
if (url.pathname === '/api/v1/usage') {
|
|
244
|
+
return json(res, 200, usageForDay(catalog, {
|
|
245
|
+
day: parseDay(url.searchParams.get('day'), timeZone),
|
|
246
|
+
timeZone,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
if (url.pathname === '/api/v1/days') {
|
|
250
|
+
return json(res, 200, { days: queryDays(catalog, {
|
|
251
|
+
timeZone,
|
|
252
|
+
limit: parseLimit(url.searchParams.get('limit'), { fallback: 14, max: 90 }),
|
|
253
|
+
}) });
|
|
254
|
+
}
|
|
255
|
+
if (url.pathname === '/api/v1/calls') {
|
|
256
|
+
const cursor = parseCursor(url.searchParams.get('cursor'), timeZone);
|
|
257
|
+
const day = url.searchParams.get('day') ? parseDay(url.searchParams.get('day'), timeZone) : null;
|
|
258
|
+
return json(res, 200, queryPage(catalog, {
|
|
259
|
+
day,
|
|
260
|
+
timeZone,
|
|
261
|
+
after: cursor.seq,
|
|
262
|
+
limit: parseLimit(url.searchParams.get('limit'), { fallback: 100, max: 200 }),
|
|
263
|
+
filters: callFilters(url),
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
return json(res, 404, { error: 'unknown api' });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return json(res, 400, { error: error.message });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (url.pathname === '/api/events' && req.method === 'GET') {
|
|
272
|
+
res.writeHead(200, {
|
|
273
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
274
|
+
'cache-control': 'no-store',
|
|
275
|
+
connection: 'keep-alive',
|
|
276
|
+
});
|
|
277
|
+
res.write('retry: 2000\n\n');
|
|
278
|
+
const after = Number(url.searchParams.get('after') ?? req.headers['last-event-id'] ?? 0);
|
|
279
|
+
if (isInitialised(ledger) && after > 0) {
|
|
280
|
+
try {
|
|
281
|
+
for (const entry of readEntries(ledger)) {
|
|
282
|
+
if (entry.action === 'llm.session') continue;
|
|
283
|
+
if (entry.seq <= after) continue;
|
|
284
|
+
const day = localDay(new Date(entry.at));
|
|
285
|
+
res.write(`id: ${entry.seq}\nevent: decision\ndata: ${JSON.stringify({ day, row: rowFor(entry), seq: entry.seq })}\n\n`);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
res.write('event: reset\ndata: {}\n\n');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const client = { res };
|
|
292
|
+
clients.add(client);
|
|
293
|
+
req.on('close', () => clients.delete(client));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (url.pathname === '/api/mode' && req.method === 'POST') {
|
|
297
|
+
try {
|
|
298
|
+
const { mode } = await body(req);
|
|
299
|
+
setModeAll(ledger, mode, { home });
|
|
300
|
+
return json(res, 200, { ok: true, mode, status: agentStatus({ dir: ledger, cwd, home }) });
|
|
301
|
+
} catch (error) { return json(res, 400, { error: error.message }); }
|
|
302
|
+
}
|
|
303
|
+
if (url.pathname === '/api/paths' && req.method === 'GET') {
|
|
304
|
+
try { return json(res, 200, readPaths(ledger)); } catch (error) { return json(res, 500, { error: error.message }); }
|
|
305
|
+
}
|
|
306
|
+
if (url.pathname === '/api/paths' && req.method === 'POST') {
|
|
307
|
+
try {
|
|
308
|
+
const next = validatePathsConfig(await body(req));
|
|
309
|
+
writeJson(paths(ledger).fsPaths, next);
|
|
310
|
+
return json(res, 200, { ok: true, paths: next });
|
|
311
|
+
} catch (error) { return json(res, 400, { error: error.message }); }
|
|
312
|
+
}
|
|
313
|
+
if (url.pathname === '/api/connect' && req.method === 'POST') {
|
|
314
|
+
try {
|
|
315
|
+
const { mode = 'observe' } = await body(req);
|
|
316
|
+
const result = connectAgent({ dir: ledger, cwd, home, write: true, mode, cli: process.argv[1] });
|
|
317
|
+
if (tail.fd === null && isInitialised(ledger)) {
|
|
318
|
+
tail.open();
|
|
319
|
+
if (!tailTimer) tailTimer = setInterval(poll, 400);
|
|
320
|
+
}
|
|
321
|
+
let pathsValue;
|
|
322
|
+
try { pathsValue = readPaths(ledger); } catch { pathsValue = null; }
|
|
323
|
+
return json(res, 200, { ok: true, result, status: { ...agentStatus({ dir: ledger, cwd, home }), paths: pathsValue } });
|
|
324
|
+
} catch (error) { return json(res, 400, { error: error.message }); }
|
|
325
|
+
}
|
|
326
|
+
return json(res, 404, { error: 'unknown api' });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return json(res, 500, { error: error.message });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const heartbeat = setInterval(() => {
|
|
333
|
+
for (const client of clients) {
|
|
334
|
+
try { client.res.write(': heartbeat\n\n'); } catch { clients.delete(client); }
|
|
335
|
+
}
|
|
336
|
+
}, 15_000);
|
|
337
|
+
|
|
338
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
339
|
+
server.once('error', rejectStart);
|
|
340
|
+
server.listen(port, '127.0.0.1', () => {
|
|
341
|
+
server.removeListener('error', rejectStart);
|
|
342
|
+
const address = server.address();
|
|
343
|
+
if (isInitialised(ledger)) {
|
|
344
|
+
try {
|
|
345
|
+
tail.open();
|
|
346
|
+
tailTimer = setInterval(poll, 400);
|
|
347
|
+
} catch {
|
|
348
|
+
// The page still reads the ledger through /api/calls; the stream waits.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const url = `http://127.0.0.1:${address.port}/`;
|
|
352
|
+
if (open) {
|
|
353
|
+
try {
|
|
354
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
355
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
356
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
357
|
+
child.on('error', () => {});
|
|
358
|
+
child.unref();
|
|
359
|
+
} catch {
|
|
360
|
+
// A headless machine can still use the URL printed by the CLI.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
resolveStart({
|
|
364
|
+
token,
|
|
365
|
+
port: address.port,
|
|
366
|
+
url,
|
|
367
|
+
server,
|
|
368
|
+
close: () => new Promise((resolveClose) => {
|
|
369
|
+
clearInterval(heartbeat);
|
|
370
|
+
if (tailTimer) clearInterval(tailTimer);
|
|
371
|
+
for (const client of clients) client.res.end();
|
|
372
|
+
clients.clear();
|
|
373
|
+
tail.close();
|
|
374
|
+
server.close(resolveClose);
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|