attesso 0.6.0 → 0.7.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/README.md +20 -1
- package/bin/attesso.mjs +24 -0
- package/package.json +1 -1
- package/src/connect.mjs +1 -1
- package/src/sync.mjs +114 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bounds
|
|
2
2
|
|
|
3
|
-
Installed as `attesso`: a local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. Free and unlimited on your own machine; $0.00085 per synced decision when the record has to travel. Any action, not only payments. No account, no hosted
|
|
3
|
+
Installed as `attesso`: a local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. Free and unlimited on your own machine; $0.00085 per synced decision when the record has to travel. Any action, not only payments. No account for local use, no runtime dependencies; hosted sync is the paid tier.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx attesso init
|
|
@@ -29,6 +29,25 @@ The server binds `127.0.0.1`, mints a per-process token, and serves the page fro
|
|
|
29
29
|
the installed package. It has no account, no hosted data, and no dependency on a
|
|
30
30
|
snapshot file. `Ctrl-C` stops it.
|
|
31
31
|
|
|
32
|
+
## Hosted sync
|
|
33
|
+
|
|
34
|
+
Hosted sync is the paid tier: it mirrors the next decisions into the Attesso
|
|
35
|
+
workspace so the hosted view can follow them from any machine. The local ledger
|
|
36
|
+
stays the source of truth. The command sends at most 50 rows per run, advances a
|
|
37
|
+
cursor under `.attesso/sync.json` only after the server confirms, and never sends
|
|
38
|
+
arguments or credentials:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
export ATTESSO_SYNC_URL=https://api.attesso.com
|
|
42
|
+
export ATTESSO_API_KEY=att_live_...
|
|
43
|
+
npx attesso sync --dry-run
|
|
44
|
+
npx attesso sync
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The API key must belong to an entitled live workspace. A retry after a failed
|
|
48
|
+
request is safe: the server deduplicates by device and sequence, and the local
|
|
49
|
+
cursor does not move until the batch is accepted.
|
|
50
|
+
|
|
32
51
|
## The overview
|
|
33
52
|
|
|
34
53
|
`attesso report` is the view that answers what the thing cost and what it stopped, per task:
|
package/bin/attesso.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { resolve } from 'node:path';
|
|
|
29
29
|
import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
|
|
30
30
|
import { validatePathsConfig } from './../src/fs-paths.mjs';
|
|
31
31
|
import { startUi } from './../src/ui/server.mjs';
|
|
32
|
+
import { sync } from './../src/sync.mjs';
|
|
32
33
|
|
|
33
34
|
const argv = process.argv.slice(2);
|
|
34
35
|
const dirAt = argv.indexOf('--dir');
|
|
@@ -76,6 +77,9 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
|
76
77
|
attesso mode [observe|enforce|off] read or set the active hook mode
|
|
77
78
|
attesso paths [--json|--set FILE] read or replace the filesystem scope
|
|
78
79
|
attesso ui [--port N] [--no-open] local control surface for the signed ledger
|
|
80
|
+
attesso sync [--url URL] [--limit N] [--json] [--dry-run]
|
|
81
|
+
send the next decisions to the hosted mirror
|
|
82
|
+
(key from ATTESSO_API_KEY; never printed)
|
|
79
83
|
attesso explain <seq> one entry in full: rule, tainting step, what to do
|
|
80
84
|
attesso suggest [--json] rules the ledger's own counts justify
|
|
81
85
|
attesso tools pin --manifest PATH pin the tool definitions a decision was made under
|
|
@@ -620,6 +624,26 @@ switch (command) {
|
|
|
620
624
|
}
|
|
621
625
|
break;
|
|
622
626
|
}
|
|
627
|
+
case 'sync': {
|
|
628
|
+
try {
|
|
629
|
+
const options = flags(rest);
|
|
630
|
+
const requested = options.limit === undefined ? 50 : Number(options.limit);
|
|
631
|
+
const result = await sync({
|
|
632
|
+
dir: DIR,
|
|
633
|
+
url: typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL,
|
|
634
|
+
key: typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY,
|
|
635
|
+
limit: requested,
|
|
636
|
+
dryRun: rest.includes('--dry-run'),
|
|
637
|
+
});
|
|
638
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
639
|
+
else if (result.dry_run) console.log(`sync dry run: ${result.entries} decision(s) ready`);
|
|
640
|
+
else console.log(`sync: ${result.accepted} accepted, ${result.skipped} already present, ${result.metered} metered; last #${result.last_seq}`);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
console.error(`sync: ${error.message}`);
|
|
643
|
+
process.exit(2);
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
623
647
|
case 'ui': {
|
|
624
648
|
try {
|
|
625
649
|
const options = flags(rest);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attesso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Bounds: decide an AI agent's next tool call before it runs, with a signed record. Free and unlimited locally, $0.00085 per synced decision.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/connect.mjs
CHANGED
|
@@ -79,7 +79,7 @@ function instructions({ mode, wired, manual }) {
|
|
|
79
79
|
if (!mode) next.push('run `attesso connect --write --mode observe` from the agent');
|
|
80
80
|
else if (mode === 'off') next.push('run `attesso mode observe` to start recording');
|
|
81
81
|
if (manual.length) next.push('wire the listed harnesses by hand, or use the generic hook contract');
|
|
82
|
-
if (!wired.length) next.push('run `attesso connect --write --mode observe` from the agent');
|
|
82
|
+
if (mode && !wired.length) next.push('run `attesso connect --write --mode observe` from the agent');
|
|
83
83
|
return next;
|
|
84
84
|
}
|
|
85
85
|
|
package/src/sync.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Hosted sync: upload the next bounded window of local decisions to Attesso.
|
|
2
|
+
// The local ledger stays authoritative; this only mirrors summary rows and
|
|
3
|
+
// advances a durable cursor after the server confirms the batch.
|
|
4
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { entryHash } from './ledger.mjs';
|
|
7
|
+
import { forEachEntrySync } from './stream.mjs';
|
|
8
|
+
import { paths, readKey } from './store.mjs';
|
|
9
|
+
|
|
10
|
+
export const SYNC_VERSION = 1;
|
|
11
|
+
export const DEFAULT_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
export function syncPath(dir) {
|
|
14
|
+
return join(dir, 'sync.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readSyncState(dir) {
|
|
18
|
+
const file = syncPath(dir);
|
|
19
|
+
if (!existsSync(file)) return { version: SYNC_VERSION, last_seq: 0 };
|
|
20
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
if (value.version !== SYNC_VERSION || !Number.isSafeInteger(value.last_seq) || value.last_seq < 0) {
|
|
22
|
+
throw new Error('sync state is invalid');
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeSyncState(dir, lastSeq, deviceId) {
|
|
28
|
+
const file = syncPath(dir);
|
|
29
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(temporary, `${JSON.stringify({ version: SYNC_VERSION, device_id: deviceId, last_seq: lastSeq }, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
renameSync(temporary, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function endpoint(base) {
|
|
35
|
+
const parsed = new URL(base);
|
|
36
|
+
if (parsed.protocol !== 'https:' && parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
|
|
37
|
+
throw new Error('sync url must use HTTPS, except on localhost');
|
|
38
|
+
}
|
|
39
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
40
|
+
parsed.pathname = path.endsWith('/v1/bounds/sync') ? path : `${path}/v1/bounds/sync`;
|
|
41
|
+
return parsed.toString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function sync({
|
|
45
|
+
dir,
|
|
46
|
+
url,
|
|
47
|
+
key,
|
|
48
|
+
limit = DEFAULT_LIMIT,
|
|
49
|
+
dryRun = false,
|
|
50
|
+
fetchImpl = fetch,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
53
|
+
throw new Error('limit must be an integer from 1 to 100');
|
|
54
|
+
}
|
|
55
|
+
const ledgerPath = paths(dir).ledger;
|
|
56
|
+
if (!existsSync(ledgerPath)) throw new Error('no ledger to sync');
|
|
57
|
+
if (!url) throw new Error('sync url is required');
|
|
58
|
+
if (!key && !dryRun) throw new Error('sync API key is required');
|
|
59
|
+
const deviceId = readKey(dir).kid;
|
|
60
|
+
if (typeof deviceId !== 'string' || !/^[A-Za-z0-9._:-]{1,64}$/.test(deviceId)) {
|
|
61
|
+
throw new Error('ledger key id is not a valid device id');
|
|
62
|
+
}
|
|
63
|
+
const state = readSyncState(dir);
|
|
64
|
+
const entries = [];
|
|
65
|
+
forEachEntrySync(ledgerPath, (entry) => {
|
|
66
|
+
if (entry.seq <= state.last_seq) return;
|
|
67
|
+
if (entries.length >= limit) return false;
|
|
68
|
+
entries.push({
|
|
69
|
+
seq: entry.seq,
|
|
70
|
+
occurred_at: entry.at,
|
|
71
|
+
action: entry.action,
|
|
72
|
+
subject: entry.subject ?? null,
|
|
73
|
+
capability: entry.capability ?? null,
|
|
74
|
+
decision: entry.decision,
|
|
75
|
+
code: entry.code,
|
|
76
|
+
task: entry.task ?? null,
|
|
77
|
+
entry_hash: entryHash(entry),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
if (entries.length === 0) {
|
|
81
|
+
return { accepted: 0, skipped: 0, metered: 0, last_seq: state.last_seq, device_id: deviceId };
|
|
82
|
+
}
|
|
83
|
+
const body = { device_id: deviceId, entries };
|
|
84
|
+
if (dryRun) {
|
|
85
|
+
return { accepted: 0, skipped: 0, metered: 0, last_seq: state.last_seq, device_id: deviceId, dry_run: true, entries: entries.length };
|
|
86
|
+
}
|
|
87
|
+
const response = await fetchImpl(endpoint(url), {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${key}`,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal: AbortSignal.timeout(20_000),
|
|
95
|
+
});
|
|
96
|
+
let payload;
|
|
97
|
+
try {
|
|
98
|
+
payload = await response.json();
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`sync failed with status ${response.status}`);
|
|
101
|
+
}
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(`sync failed with status ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
const accepted = Number(payload.accepted);
|
|
106
|
+
const skipped = Number(payload.skipped);
|
|
107
|
+
const metered = Number(payload.metered);
|
|
108
|
+
if (![accepted, skipped, metered].every(Number.isSafeInteger) || accepted < 0 || skipped < 0 || metered < 0) {
|
|
109
|
+
throw new Error('sync response is invalid');
|
|
110
|
+
}
|
|
111
|
+
const lastSeq = entries[entries.length - 1].seq;
|
|
112
|
+
writeSyncState(dir, lastSeq, deviceId);
|
|
113
|
+
return { accepted, skipped, metered, last_seq: lastSeq, device_id: deviceId };
|
|
114
|
+
}
|