attesso 0.6.1 → 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 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 backend, no dependencies.
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.6.1",
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/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
+ }