attesso 0.0.0-stage → 0.3.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,3 +1,118 @@
1
- # Temporary Holding Version
1
+ # attesso
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. Any action, not only payments. No account, no server, no dependencies.
4
+
5
+ ```sh
6
+ npx attesso init
7
+ npx attesso log --action tool.call --subject read_file --meta tokens=1200 --meta cost_usd=0.0004
8
+ npx attesso log --action purchase.execute --subject shop.example --amount 12.50 --currency EUR
9
+ npx attesso verify
10
+ npx attesso show
11
+ npx attesso report
12
+ ```
13
+
14
+ `init` writes `.attesso/` with an Ed25519 key, a policy and an empty ledger. `log` evaluates one proposed action against the policy, records the decision and its reason, and exits 1 when it refuses. `verify` recomputes the hash chain and every signature. `show` prints what was allowed, what was refused, which subjects dominate, and any tokens or cost you passed in `--meta`. `--dir PATH` points any command at a different ledger.
15
+
16
+ ## The overview
17
+
18
+ `attesso report` is the view that answers what the thing cost and what it stopped, per task:
19
+
20
+ ```
21
+ 2059 entries, key 1qTU-tGyjuQwt3kI: 2058 actions, 455 refused (22%), 3,922,488 tokens, 3.1932 USD
22
+
23
+ task actions refused top tokens cached cost
24
+ ────────────────────────────────── ─────── ───────── ─────────────── ───────── ─────────── ──────
25
+ hermes-local-9552aa28437e88c0610a… 2058 455 (22%) PATH_DENIED×455 3,922,488 645,785,088 3.1932
26
+ ```
27
+
28
+ Actions are tool calls; refusals carry the reason; tokens are input plus output, with cache reads shown separately because they bill at a different rate and dwarf the rest. Cost comes from a `llm.session` entry the recorder writes from the agent's own usage record, never from a number the report invents. `--json` gives the same data for a dashboard.
29
+
30
+ ## What it records
31
+
32
+ One signed line per proposed action:
33
+
34
+ ```json
35
+ {"v":1,"seq":1,"at":"2026-10-31T12:00:00.000Z","action":"tool.call",
36
+ "subject":"read_file","decision":"ALLOW","code":"ALLOW","policy_digest":"…",
37
+ "kid":"…","prev":"genesis","meta":{"tokens":1200,"cost_usd":0.0004},"sig":"…"}
38
+ ```
39
+
40
+ `subject` is the thing acted on: a tool name, a payee, a repo, a recipient. `amount` appears only for money actions. Refusal codes match the hosted engine: `ACTION_MISMATCH`, `ABOVE_MAXIMUM`, `VALUE_NOT_ALLOWED`, `VALUE_MISMATCH`, `OUTSIDE_DATE_RANGE`, `REQUIRED_ATTRIBUTE_MISSING`.
41
+
42
+ ## The policy
43
+
44
+ `.attesso/policy.json` starts empty, so nothing is allowed until you set it. A policy governs one action; `"action": "*"` covers every action and is what a recording-only ledger uses.
45
+
46
+ ```json
47
+ { "version": 1, "action": "tool.call", "subjects": ["read_file", "search_files", "grep_repo"] }
48
+ ```
49
+
50
+ ```json
51
+ {
52
+ "version": 1,
53
+ "action": "purchase.execute",
54
+ "currency": "EUR",
55
+ "max_amount": 25000,
56
+ "subjects": ["shop.example"],
57
+ "on_or_after": "2026-11-01T00:00:00Z",
58
+ "on_or_before": "2026-12-01T00:00:00Z"
59
+ }
60
+ ```
61
+
62
+ Money is optional: without `max_amount` and `currency` the policy is judged on action, subject and window alone. Amounts in the policy are whole minor units (25000 = 250.00 EUR); `log --amount` takes the decimal form.
63
+
64
+ ## Policies on paths
65
+
66
+ A single step can look fine and the sequence still be wrong: reading an untrusted page and then sending it somewhere is an exfiltration no per-step check can see. `attesso` judges an action against the path of its task, not in isolation. Three flags give it the shape of the step, and `.attesso/rules.json` says what a path may not do.
67
+
68
+ ```sh
69
+ npx attesso log --action web.read --subject docs.example --task job-1 --trust untrusted --capability read
70
+ npx attesso log --action mail.send --subject partner@example.com --task job-1 --capability send
71
+ # #2 DENY mail.send partner@example.com (PATH_DENIED by step #1 under no-send-after-untrusted-read)
72
+
73
+ npx attesso log --action file.read --subject src/app.ts --task job-2 --trust internal --capability read
74
+ npx attesso log --action mail.send --subject partner@example.com --task job-2 --capability send
75
+ # #4 ALLOW mail.send partner@example.com (ALLOW)
76
+ ```
77
+
78
+ `init` ships one rule, and it is the one worth having:
79
+
80
+ ```json
81
+ { "version": 1,
82
+ "rules": [
83
+ { "id": "no-send-after-untrusted-read",
84
+ "deny": { "capabilities": ["send", "execute"] },
85
+ "if_path": { "trust": "untrusted", "capability": "read", "within_minutes": 30 } }
86
+ ] }
87
+ ```
88
+
89
+ `--task` scopes the history, so one job cannot taint another. `within_minutes` limits how long a read stays relevant; without it a taint lasts for the whole task. `--capability` is one of `read`, `send`, `execute`, `spend`. The refusal names the rule and the step that tainted the path, so a reader can see the sequence rather than a verdict.
90
+
91
+ Rules are ordinary files you own and version. Run them in observe mode first: `node scripts/hermes-ledger.mjs --observe` records what the rules would have refused without touching the agent, which is how you learn where a rule is too broad before it costs you a day.
92
+
93
+ ## Recording a real agent
94
+
95
+ `attesso` is a CLI, so anything that can run a command can record an action. To see what a Hermes session actually did, the repo ships `scripts/hermes-ledger.mjs`, which reads a session from `state.db` read-only and appends one entry per tool call.
96
+
97
+ ```sh
98
+ node scripts/hermes-ledger.mjs --dir .hermes/tmp/attesso-dogfood --observe
99
+ npx attesso --dir .hermes/tmp/attesso-dogfood show
100
+ ```
101
+
102
+ `--observe` labels every verdict the rules produced and prints how many steps they would have refused; nothing in the agent is blocked. Tool calls are classified on the way in: `read_file` and the repo search tools read internal material, the web and browser tools read untrusted material, and anything that writes, executes or spawns is an effect.
103
+
104
+ ## What this proves, and what it does not
105
+
106
+ The signature proves the ledger was not edited after the fact: change one byte and `verify` names the entry, reorder two and the chain breaks, delete the last one and the recorded head no longer matches.
107
+
108
+ It does not prove a human authorized anything. The signing key lives in `.attesso/key.json`, in the same directory as the log, so anything that can write the log can also write a new one. It cannot see absence either: a process that deletes the whole directory leaves nothing to verify.
109
+
110
+ Hosted Attesso is where authority lives: a passkey approval binds a named human to exact terms, the decision and the refusal are recorded server-side, and the result is evidence a third party verifies offline against a published key. The local ledger uses the same event and code vocabulary, so it can be submitted there later.
111
+
112
+ ## Exit codes
113
+
114
+ `0` allowed or verified, `1` refused or a failed verification, `2` usage error.
115
+
116
+ ## License
117
+
118
+ Apache-2.0.
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ // attesso: a local, signed ledger of what your agents attempted.
3
+ //
4
+ // attesso init
5
+ // attesso log --action tool.call --subject read_file --meta tokens=1200 --meta cost_usd=0.0004
6
+ // attesso log --action purchase.execute --subject shop.example --amount 12.50 --currency EUR
7
+ // attesso verify
8
+ // attesso show
9
+ import { verifyChain } from './../src/ledger.mjs';
10
+ import { formatMinor, toMinor } from './../src/policy.mjs';
11
+ import { append, init, isInitialised, paths, readState } from './../src/store.mjs';
12
+ import { buildReport, renderReport } from './../src/report.mjs';
13
+
14
+ const argv = process.argv.slice(2);
15
+ const dirAt = argv.indexOf('--dir');
16
+ const DIR = dirAt === -1 ? '.attesso' : argv[dirAt + 1];
17
+ if (dirAt !== -1) argv.splice(dirAt, 2);
18
+
19
+ const USAGE = `attesso: a local, signed ledger of what your agents attempted
20
+
21
+ attesso init [--dir PATH] create .attesso with a key and a policy
22
+ attesso log --action A [--subject S] [--amount 12.50 --currency EUR]
23
+ [--task T] [--trust internal|untrusted] [--capability read|send|execute|spend]
24
+ [--meta k=v ...] [--json]
25
+ evaluate one proposed action against the policy
26
+ and the path of its task, then record it
27
+ attesso verify recompute the chain and report the first defect
28
+ attesso show what was allowed, what was refused, and why
29
+ attesso report [--json] per task: actions, refusals with reasons, tokens and cost
30
+
31
+ Any action can be logged, not only payments. --subject is the thing acted on (a
32
+ tool name, a payee, a repo). --task scopes the path, --trust marks where the
33
+ content came from, and --capability says what the step does; with those three, a
34
+ rule in .attesso/rules.json can refuse a send that follows an untrusted read,
35
+ which no per-step check can see. --meta carries whatever you want counted later
36
+ (tokens, cost_usd, duration_ms, model).
37
+
38
+ The log is signed with a key that lives in .attesso/key.json. That proves the
39
+ ledger was not edited; it does not prove a human authorized anything. Hosted
40
+ Attesso adds passkey approval and evidence a third party verifies offline.`;
41
+
42
+ function flags(argv) {
43
+ const out = {};
44
+ for (let index = 0; index < argv.length; index += 1) {
45
+ const token = argv[index];
46
+ if (!token.startsWith('--')) continue;
47
+ const name = token.slice(2);
48
+ const next = argv[index + 1];
49
+ if (next === undefined || next.startsWith('--')) {
50
+ out[name] = true;
51
+ continue;
52
+ }
53
+ if (name === 'meta') {
54
+ out.meta = [...(out.meta ?? []), next];
55
+ } else {
56
+ out[name] = next;
57
+ }
58
+ index += 1;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function parseMeta(pairs = []) {
64
+ const meta = {};
65
+ for (const pair of pairs) {
66
+ const at = pair.indexOf('=');
67
+ if (at <= 0) throw new Error(`--meta expects key=value, got ${pair}`);
68
+ const key = pair.slice(0, at);
69
+ const raw = pair.slice(at + 1);
70
+ meta[key] = raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
71
+ }
72
+ return meta;
73
+ }
74
+
75
+ function requireLedger() {
76
+ if (!isInitialised(DIR)) {
77
+ console.error(`no ledger at ${paths(DIR).dir}; run attesso init first`);
78
+ process.exit(2);
79
+ }
80
+ }
81
+
82
+ function describe(entry) {
83
+ const subject = entry.subject ? ` ${entry.subject}` : '';
84
+ const amount = entry.amount ? ` ${formatMinor(entry.amount.minor, entry.amount.currency)} ${entry.amount.currency}` : '';
85
+ return `${entry.action}${subject}${amount}`;
86
+ }
87
+
88
+ function log(argv) {
89
+ requireLedger();
90
+ const options = flags(argv);
91
+ if (typeof options.action !== 'string') {
92
+ console.error('attesso log needs --action');
93
+ process.exit(2);
94
+ }
95
+ if ((options.amount === undefined) !== (options.currency === undefined)) {
96
+ console.error('--amount and --currency go together');
97
+ process.exit(2);
98
+ }
99
+ let amount;
100
+ if (options.amount !== undefined) {
101
+ try {
102
+ amount = { minor: toMinor(options.amount, options.currency), currency: options.currency };
103
+ } catch (error) {
104
+ console.error(error.message);
105
+ process.exit(1);
106
+ }
107
+ }
108
+ let meta;
109
+ try {
110
+ meta = parseMeta(options.meta);
111
+ } catch (error) {
112
+ console.error(error.message);
113
+ process.exit(2);
114
+ }
115
+
116
+ if (options.trust !== undefined && !['internal', 'untrusted', 'unknown'].includes(options.trust)) {
117
+ console.error('--trust takes internal, untrusted or unknown');
118
+ process.exit(2);
119
+ }
120
+ if (options.capability !== undefined && !['read', 'send', 'execute', 'spend'].includes(options.capability)) {
121
+ console.error('--capability takes read, send, execute or spend');
122
+ process.exit(2);
123
+ }
124
+ const entry = append(DIR, {
125
+ action: options.action,
126
+ subject: options.subject,
127
+ amount,
128
+ meta,
129
+ task: typeof options.task === 'string' ? options.task : undefined,
130
+ trust: options.trust,
131
+ capability: options.capability,
132
+ });
133
+ if (options.json) {
134
+ console.log(JSON.stringify(entry));
135
+ return;
136
+ }
137
+ const taint = entry.meta?.tainted_by === undefined ? '' : ` by step #${entry.meta.tainted_by} under ${entry.meta.rule}`;
138
+ console.log(`#${entry.seq} ${entry.decision} ${describe(entry)} (${entry.code}${taint})`);
139
+ process.exitCode = entry.decision === 'ALLOW' ? 0 : 1;
140
+ }
141
+
142
+ function verify() {
143
+ requireLedger();
144
+ const state = readState(DIR);
145
+ const result = verifyChain(state.entries, state.key.public, state.config.head);
146
+ if (!result.ok) {
147
+ console.error(`ledger does not verify: ${result.reason}`);
148
+ process.exit(1);
149
+ }
150
+ console.log(`ledger verifies: ${result.reason}, key ${state.key.kid}`);
151
+ }
152
+
153
+ function totals(entries) {
154
+ const sum = (field) =>
155
+ entries.reduce((total, entry) => total + (Number(entry.meta?.[field]) || 0), 0);
156
+ const tokens = sum('tokens');
157
+ const cost = sum('cost_usd');
158
+ const parts = [];
159
+ if (tokens > 0) parts.push(`${tokens.toLocaleString('en-US')} tokens`);
160
+ if (cost > 0) parts.push(`${cost.toFixed(4)} USD`);
161
+ return parts.length > 0 ? `, ${parts.join(', ')}` : '';
162
+ }
163
+
164
+ function show() {
165
+ requireLedger();
166
+ const { entries, key } = readState(DIR);
167
+ if (entries.length === 0) {
168
+ console.log('no decisions yet');
169
+ return;
170
+ }
171
+ const counts = new Map();
172
+ for (const entry of entries) {
173
+ const label = `${entry.decision} ${entry.code}`;
174
+ counts.set(label, (counts.get(label) ?? 0) + 1);
175
+ }
176
+ const allowed = entries.filter((entry) => entry.decision === 'ALLOW').length;
177
+ console.log(
178
+ `${entries.length} decisions: ${allowed} allowed, ${entries.length - allowed} refused${totals(entries)} (key ${key.kid})`,
179
+ );
180
+ for (const [label, count] of [...counts.entries()].sort()) {
181
+ console.log(` ${String(count).padStart(4)} ${label}`);
182
+ }
183
+ const bySubject = new Map();
184
+ for (const entry of entries) {
185
+ const label = entry.subject ?? '(none)';
186
+ bySubject.set(label, (bySubject.get(label) ?? 0) + 1);
187
+ }
188
+ if (bySubject.size > 0 && [...bySubject.keys()].some((label) => label !== '(none)')) {
189
+ console.log('subjects:');
190
+ for (const [label, count] of [...bySubject.entries()].sort((left, right) => right[1] - left[1])) {
191
+ console.log(` ${String(count).padStart(4)} ${label}`);
192
+ }
193
+ }
194
+ console.log('latest:');
195
+ for (const entry of entries.slice(-5)) {
196
+ console.log(` #${entry.seq} ${entry.at} ${entry.decision} ${describe(entry)} (${entry.code})`);
197
+ }
198
+ }
199
+
200
+ const [command, ...rest] = argv;
201
+ switch (command) {
202
+ case 'init': {
203
+ try {
204
+ const result = init(DIR);
205
+ console.log(`created ${result.dir} with key ${result.kid}`);
206
+ console.log('edit .attesso/policy.json to set what is allowed (it starts at nothing)');
207
+ } catch (error) {
208
+ console.error(error.message);
209
+ process.exit(1);
210
+ }
211
+ break;
212
+ }
213
+ case 'log':
214
+ log(rest);
215
+ break;
216
+ case 'verify':
217
+ verify();
218
+ break;
219
+ case 'show':
220
+ show();
221
+ break;
222
+ case 'report': {
223
+ requireLedger();
224
+ const report = buildReport(DIR);
225
+ if (rest.includes('--json')) {
226
+ console.log(JSON.stringify(report, null, 2));
227
+ } else {
228
+ process.stdout.write(renderReport(report));
229
+ }
230
+ break;
231
+ }
232
+ case undefined:
233
+ console.log(USAGE);
234
+ break;
235
+ default:
236
+ if (command === '--help' || command === '-h' || command === 'help') {
237
+ console.log(USAGE);
238
+ break;
239
+ }
240
+ console.error(`unknown command ${command}\n`);
241
+ console.log(USAGE);
242
+ process.exit(2);
243
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "attesso": "./bin/attesso.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/*.test.mjs",
20
+ "prepublishOnly": "node --test test/*.test.mjs"
21
+ },
22
+ "keywords": [
23
+ "ai-agents",
24
+ "agent-governance",
25
+ "audit",
26
+ "ledger",
27
+ "guardrails"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://codeberg.org/douwe/attesso.git",
32
+ "directory": "packages/attesso"
33
+ }
34
+ }
@@ -0,0 +1,8 @@
1
+ // One canonical form for anything that is hashed or signed. Key order must not
2
+ // depend on how a caller happened to build the object.
3
+ export function canonical(value) {
4
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
5
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
6
+ const keys = Object.keys(value).sort();
7
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
8
+ }
package/src/ledger.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // The ledger itself: one signed entry per proposed action, chained so an edit
2
+ // breaks every later entry. Ed25519 keys generated on first use; the private key
3
+ // stays in the project directory, which is why a local log proves integrity and
4
+ // not authority.
5
+ import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto';
6
+ import { canonical } from './canonical.mjs';
7
+
8
+ export const ENTRY_VERSION = 1;
9
+
10
+ export function newSigningKey() {
11
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
12
+ return {
13
+ kid: keyId(publicKey.export({ format: 'jwk' })),
14
+ publicPem: publicKey.export({ type: 'spki', format: 'pem' }),
15
+ privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
16
+ };
17
+ }
18
+
19
+ // RFC 7638 style thumbprint: the members that identify an Ed25519 key, sorted.
20
+ export function keyId(publicJwk) {
21
+ const thumbprint = canonical({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x });
22
+ return createHash('sha256').update(thumbprint).digest('base64url').slice(0, 16);
23
+ }
24
+
25
+ export function entryHash(entry) {
26
+ return createHash('sha256').update(canonical(entry)).digest('base64url');
27
+ }
28
+
29
+ function signingPayload(entry) {
30
+ const { sig, ...rest } = entry;
31
+ return Buffer.from(canonical(rest));
32
+ }
33
+
34
+ export function signEntry(entry, privatePem) {
35
+ return sign(null, signingPayload(entry), privatePem).toString('base64url');
36
+ }
37
+
38
+ export function verifyEntry(entry, publicPem) {
39
+ return verify(null, signingPayload(entry), publicPem, Buffer.from(entry.sig, 'base64url'));
40
+ }
41
+
42
+ export function buildEntry({ seq, prev, action, subject, amount, meta, task, trust, capability, decision, code, policyDigest, kid, at }) {
43
+ const entry = {
44
+ v: ENTRY_VERSION,
45
+ seq,
46
+ at: at ?? new Date().toISOString(),
47
+ action,
48
+ subject: subject ?? null,
49
+ decision,
50
+ code,
51
+ policy_digest: policyDigest,
52
+ kid,
53
+ prev,
54
+ };
55
+ if (amount) entry.amount = amount;
56
+ if (task) entry.task = task;
57
+ if (trust) entry.trust = trust;
58
+ if (capability) entry.capability = capability;
59
+ if (meta && Object.keys(meta).length > 0) entry.meta = meta;
60
+ return entry;
61
+ }
62
+
63
+ // Returns the first defect rather than a boolean, so a reader learns which entry
64
+ // to look at. `head` comes from config, which is what makes truncation visible.
65
+ export function verifyChain(entries, publicPem, head) {
66
+ let previous = 'genesis';
67
+ for (const [index, entry] of entries.entries()) {
68
+ if (entry.seq !== index + 1) {
69
+ return { ok: false, reason: `entry ${index + 1} carries seq ${entry.seq}` };
70
+ }
71
+ if (entry.prev !== previous) {
72
+ return { ok: false, reason: `entry ${entry.seq} does not chain to the entry before it` };
73
+ }
74
+ if (!verifyEntry(entry, publicPem)) {
75
+ return { ok: false, reason: `entry ${entry.seq} signature does not verify` };
76
+ }
77
+ previous = entryHash(entry);
78
+ }
79
+ if (!head) return { ok: false, reason: 'no recorded head, so the end of the log cannot be checked' };
80
+ if (head.count !== entries.length) {
81
+ return { ok: false, reason: `config records ${head.count} entries, the log holds ${entries.length}` };
82
+ }
83
+ if (head.hash !== previous) {
84
+ return { ok: false, reason: 'the last entry does not match the recorded head' };
85
+ }
86
+ return { ok: true, reason: `chain of ${entries.length} entries verifies` };
87
+ }
package/src/policy.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Deterministic local policy, for any agent action, money or not. Codes match
2
+ // the hosted engine so a local refusal reads the same as a server one.
3
+ import { createHash } from 'node:crypto';
4
+ import { canonical } from './canonical.mjs';
5
+
6
+ export const ALLOW = 'ALLOW';
7
+
8
+ const EXPONENTS = { EUR: 2, USD: 2, GBP: 2, CHF: 2, SEK: 2, JPY: 0 };
9
+
10
+ export function toMinor(amount, currency) {
11
+ const exponent = EXPONENTS[currency];
12
+ if (exponent === undefined) {
13
+ throw new Error(`unsupported currency ${currency}; add its exponent to src/policy.mjs`);
14
+ }
15
+ if (!/^\d+(\.\d+)?$/.test(String(amount))) throw new Error(`amount ${amount} is not a positive decimal`);
16
+ const [whole, fraction = ''] = String(amount).split('.');
17
+ if (fraction.length > exponent) {
18
+ throw new Error(`amount ${amount} has more than ${exponent} decimal places for ${currency}`);
19
+ }
20
+ return Number(whole) * 10 ** exponent + Number(fraction.padEnd(exponent, '0'));
21
+ }
22
+
23
+ export function formatMinor(minor, currency) {
24
+ const exponent = EXPONENTS[currency] ?? 2;
25
+ const scale = 10 ** exponent;
26
+ return `${Math.trunc(minor / scale)}.${String(minor % scale).padStart(exponent, '0')}`;
27
+ }
28
+
29
+ export function policyDigest(policy) {
30
+ return createHash('sha256').update(canonical(policy)).digest('base64url');
31
+ }
32
+
33
+ // A policy is about one action. Money is optional: an action like tool.call or
34
+ // repo.write has bounds but no amount, and saying so with max_amount is wrong.
35
+ export function validatePolicy(policy) {
36
+ const fail = (message) => {
37
+ throw new Error(`policy is invalid: ${message}`);
38
+ };
39
+ if (policy === null || typeof policy !== 'object') fail('not an object');
40
+ if (policy.version !== 1) fail('version must be 1');
41
+ if (typeof policy.action !== 'string' || policy.action === '') fail('action must be a non-empty string');
42
+ if (policy.action !== '*' && policy.action.trim() === '') fail('action must be a non-empty string or *');
43
+
44
+ const money = policy.max_amount !== undefined || policy.currency !== undefined;
45
+ if (money) {
46
+ if (!EXPONENTS[policy.currency]) fail(`currency ${policy.currency} is not supported`);
47
+ if (!Number.isInteger(policy.max_amount) || policy.max_amount < 0) {
48
+ fail('max_amount must be a whole number of minor units, 0 or more');
49
+ }
50
+ }
51
+ if (policy.subjects !== undefined) {
52
+ if (!Array.isArray(policy.subjects) || policy.subjects.some((item) => typeof item !== 'string' || item === '')) {
53
+ fail('subjects must be a list of non-empty strings when present');
54
+ }
55
+ }
56
+ for (const field of ['on_or_after', 'on_or_before']) {
57
+ if (policy[field] !== undefined && Number.isNaN(Date.parse(policy[field]))) {
58
+ fail(`${field} must be an ISO date when present`);
59
+ }
60
+ }
61
+ return policy;
62
+ }
63
+
64
+ // Order matters for the reader: the most specific reason wins, so a refusal
65
+ // names one cause rather than a list.
66
+ export function evaluate(policy, proposal, now = new Date()) {
67
+ if (policy.action !== '*' && proposal.action !== policy.action) {
68
+ return { decision: 'DENY', code: 'ACTION_MISMATCH' };
69
+ }
70
+ if (policy.subjects !== undefined) {
71
+ if (typeof proposal.subject !== 'string') {
72
+ return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING' };
73
+ }
74
+ if (!policy.subjects.includes(proposal.subject)) {
75
+ return { decision: 'DENY', code: 'VALUE_NOT_ALLOWED' };
76
+ }
77
+ }
78
+ if (policy.max_amount !== undefined) {
79
+ if (!proposal.amount || !Number.isInteger(proposal.amount.minor)) {
80
+ return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING' };
81
+ }
82
+ if (proposal.amount.currency !== policy.currency) {
83
+ return { decision: 'DENY', code: 'VALUE_MISMATCH' };
84
+ }
85
+ if (proposal.amount.minor > policy.max_amount) {
86
+ return { decision: 'DENY', code: 'ABOVE_MAXIMUM' };
87
+ }
88
+ }
89
+ const at = now.getTime();
90
+ if (policy.on_or_after && at < Date.parse(policy.on_or_after)) {
91
+ return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
92
+ }
93
+ if (policy.on_or_before && at > Date.parse(policy.on_or_before)) {
94
+ return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
95
+ }
96
+ return { decision: ALLOW, code: ALLOW };
97
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // The overview that answers "what did this actually cost me, and what did it
2
+ // stop": per task, how many actions ran, how many were refused and why, and the
3
+ // tokens and money the task spent. Costs come from a llm.session entry the
4
+ // recorder writes, never from an estimate the report invents.
5
+ import { readState } from './store.mjs';
6
+
7
+ const isSpend = (entry) => entry.action === 'llm.session';
8
+ const isAction = (entry) => !isSpend(entry);
9
+
10
+ function summarise(entries) {
11
+ const actions = entries.filter(isAction);
12
+ const blocked = actions.filter((entry) => entry.decision !== 'ALLOW');
13
+ const reasons = new Map();
14
+ for (const entry of blocked) {
15
+ const key = entry.code ?? 'UNKNOWN';
16
+ reasons.set(key, (reasons.get(key) ?? 0) + 1);
17
+ }
18
+ const sum = (field) => entries.reduce((total, entry) => total + (Number(entry.meta?.[field]) || 0), 0);
19
+ return {
20
+ actions: actions.length,
21
+ allowed: actions.length - blocked.length,
22
+ blocked: blocked.length,
23
+ blocked_share: actions.length === 0 ? null : blocked.length / actions.length,
24
+ reasons: [...reasons.entries()].sort((left, right) => right[1] - left[1]).map(([code, count]) => ({ code, count })),
25
+ tokens: sum('tokens'),
26
+ cached_tokens: sum('cached_tokens'),
27
+ cost_usd: Number(sum('cost_usd').toFixed(6)),
28
+ api_calls: sum('api_calls'),
29
+ };
30
+ }
31
+
32
+ export function buildReport(dir) {
33
+ const { entries, key } = readState(dir);
34
+ const groups = new Map();
35
+ for (const entry of entries) {
36
+ const task = entry.task ?? '(unscoped)';
37
+ if (!groups.has(task)) groups.set(task, []);
38
+ groups.get(task).push(entry);
39
+ }
40
+ const tasks = [...groups.entries()]
41
+ .map(([task, rows]) => ({ task, ...summarise(rows) }))
42
+ .sort((left, right) => right.actions - left.actions);
43
+ const totals = summarise(entries);
44
+ return { key: key.kid, entries: entries.length, tasks, totals };
45
+ }
46
+
47
+ const percent = (share) => (share === null ? 'n/a' : `${Math.round(share * 100)}%`);
48
+
49
+ export function renderReport(report) {
50
+ const lines = [];
51
+ lines.push(
52
+ `${report.entries} entries, key ${report.key}: ${report.totals.actions} actions, ` +
53
+ `${report.totals.blocked} refused (${percent(report.totals.blocked_share)}), ` +
54
+ `${report.totals.tokens.toLocaleString('en-US')} tokens, ${report.totals.cost_usd.toFixed(4)} USD`,
55
+ );
56
+ lines.push('');
57
+ const rows = report.tasks.map((task) => ({
58
+ task: task.task.length > 34 ? `${task.task.slice(0, 33)}…` : task.task,
59
+ actions: String(task.actions),
60
+ refused: `${task.blocked} (${percent(task.blocked_share)})`,
61
+ top: task.reasons.slice(0, 2).map((reason) => `${reason.code}×${reason.count}`).join(' '),
62
+ tokens: task.tokens.toLocaleString('en-US'),
63
+ cached: task.cached_tokens > 0 ? task.cached_tokens.toLocaleString('en-US') : '',
64
+ cost: task.cost_usd > 0 ? task.cost_usd.toFixed(4) : '',
65
+ }));
66
+ const width = (field) => Math.max(field.length, ...rows.map((row) => String(row[field]).length));
67
+ const columns = ['task', 'actions', 'refused', 'top', 'tokens', 'cached', 'cost'];
68
+ const sizes = Object.fromEntries(columns.map((column) => [column, width(column)]));
69
+ const header = columns.map((column) => column.padEnd(sizes[column])).join(' ');
70
+ lines.push(header);
71
+ lines.push(columns.map((column) => '─'.repeat(sizes[column])).join(' '));
72
+ for (const row of rows) {
73
+ lines.push(columns.map((column) => String(row[column]).padEnd(sizes[column])).join(' '));
74
+ }
75
+ return `${lines.join('\n')}\n`;
76
+ }
package/src/rules.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // Policies on paths: an action is judged against the ordered history of the same
2
+ // task, not in isolation. A read of something untrusted followed by a send is the
3
+ // case no per-step check can catch, and it is the reason this file exists.
4
+ import { createHash } from 'node:crypto';
5
+ import { canonical } from './canonical.mjs';
6
+
7
+ export const CAPABILITIES = ['read', 'send', 'execute', 'spend'];
8
+ export const TRUST_LEVELS = ['internal', 'untrusted', 'unknown'];
9
+
10
+ export function rulesDigest(rules) {
11
+ return createHash('sha256').update(canonical(rules)).digest('base64url');
12
+ }
13
+
14
+ export function validateRules(rules) {
15
+ const fail = (message) => {
16
+ throw new Error(`rules are invalid: ${message}`);
17
+ };
18
+ if (rules === null || typeof rules !== 'object') fail('not an object');
19
+ if (rules.version !== 1) fail('version must be 1');
20
+ if (!Array.isArray(rules.rules)) fail('rules must be a list');
21
+ for (const rule of rules.rules) {
22
+ if (typeof rule.id !== 'string' || rule.id === '') fail('every rule needs an id');
23
+ const deny = rule.deny ?? {};
24
+ const denies = deny.capabilities ?? (deny.capability ? [deny.capability] : []);
25
+ if (denies.length === 0) fail(`rule ${rule.id} denies nothing`);
26
+ for (const capability of denies) {
27
+ if (!CAPABILITIES.includes(capability)) fail(`rule ${rule.id} denies unknown capability ${capability}`);
28
+ }
29
+ const trigger = rule.if_path ?? {};
30
+ if (trigger.trust && !TRUST_LEVELS.includes(trigger.trust)) fail(`rule ${rule.id} names unknown trust ${trigger.trust}`);
31
+ if (trigger.capability && !CAPABILITIES.includes(trigger.capability)) {
32
+ fail(`rule ${rule.id} triggers on unknown capability ${trigger.capability}`);
33
+ }
34
+ if (trigger.trust === undefined && trigger.capability === undefined) {
35
+ fail(`rule ${rule.id} has no path condition`);
36
+ }
37
+ if (trigger.within_minutes !== undefined && (!Number.isInteger(trigger.within_minutes) || trigger.within_minutes < 0)) {
38
+ fail(`rule ${rule.id} needs whole minutes for within_minutes`);
39
+ }
40
+ }
41
+ return rules;
42
+ }
43
+
44
+ // The most recent matching step taints the path; naming it is what makes the
45
+ // refusal explainable rather than mysterious.
46
+ export function evaluatePath(rules, history, proposal, now = new Date()) {
47
+ if (!rules) return null;
48
+ for (const rule of rules.rules ?? []) {
49
+ const deny = rule.deny ?? {};
50
+ const denied = deny.capabilities ?? (deny.capability ? [deny.capability] : []);
51
+ if (proposal.capability && !denied.includes(proposal.capability)) continue;
52
+ const trigger = rule.if_path ?? {};
53
+ const window = (trigger.within_minutes ?? 0) * 60_000;
54
+ const hit = [...history].reverse().find((entry) => {
55
+ if (trigger.trust && entry.trust !== trigger.trust) return false;
56
+ if (trigger.capability && entry.capability !== trigger.capability) return false;
57
+ if (window > 0 && now.getTime() - Date.parse(entry.at) > window) return false;
58
+ return true;
59
+ });
60
+ if (hit) {
61
+ return { decision: 'DENY', code: 'PATH_DENIED', rule: rule.id, tainted_by: hit.seq };
62
+ }
63
+ }
64
+ return null;
65
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,165 @@
1
+ // File layout under .attesso/:
2
+ // key.json Ed25519 key pair, 0600, generated by init
3
+ // policy.json the bounds this project allows
4
+ // ledger.jsonl one signed entry per line
5
+ // config.json the recorded head (count + hash), the only thing that makes a
6
+ // truncated log visible
7
+ // lock held while appending, so two agents cannot interleave writes
8
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { buildEntry, entryHash, newSigningKey, signEntry } from './ledger.mjs';
11
+ import { evaluate, policyDigest, validatePolicy } from './policy.mjs';
12
+ import { evaluatePath, rulesDigest, validateRules } from './rules.mjs';
13
+
14
+ export function paths(dir) {
15
+ return {
16
+ dir,
17
+ key: join(dir, 'key.json'),
18
+ policy: join(dir, 'policy.json'),
19
+ rules: join(dir, 'rules.json'),
20
+ ledger: join(dir, 'ledger.jsonl'),
21
+ config: join(dir, 'config.json'),
22
+ lock: join(dir, 'lock'),
23
+ };
24
+ }
25
+
26
+ const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
27
+
28
+ function writeJson(path, value) {
29
+ const temporary = `${path}.tmp`;
30
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
31
+ renameSync(temporary, path);
32
+ }
33
+
34
+ export function isInitialised(dir) {
35
+ return existsSync(paths(dir).key);
36
+ }
37
+
38
+ export function init(dir) {
39
+ if (isInitialised(dir)) {
40
+ throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
41
+ }
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ const file = paths(dir);
44
+ const key = newSigningKey();
45
+ writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
46
+ writeJson(file.policy, {
47
+ version: 1,
48
+ action: 'EXAMPLE.action',
49
+ subjects: [],
50
+ });
51
+ writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
52
+ writeJson(file.rules, {
53
+ version: 1,
54
+ rules: [
55
+ { id: 'no-send-after-untrusted-read', deny: { capabilities: ['send', 'execute'] }, if_path: { trust: 'untrusted', capability: 'read' } },
56
+ ],
57
+ });
58
+ writeFileSync(file.ledger, '', { mode: 0o600 });
59
+ return { dir, kid: key.kid };
60
+ }
61
+
62
+ export function readState(dir) {
63
+ const file = paths(dir);
64
+ return {
65
+ key: readJson(file.key),
66
+ config: readJson(file.config),
67
+ policy: readJson(file.policy),
68
+ rules: existsSync(file.rules) ? readJson(file.rules) : null,
69
+ entries: readEntries(dir),
70
+ };
71
+ }
72
+
73
+ export function readEntries(dir) {
74
+ const text = readFileSync(paths(dir).ledger, 'utf8');
75
+ if (text === '') return [];
76
+ return text
77
+ .split('\n')
78
+ .filter((line) => line !== '')
79
+ .map((line, index) => {
80
+ try {
81
+ return JSON.parse(line);
82
+ } catch {
83
+ throw new Error(`ledger line ${index + 1} is not JSON; the file was edited or truncated mid-line`);
84
+ }
85
+ });
86
+ }
87
+
88
+ function processAlive(pid) {
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ function withLock(dir, run) {
98
+ const { lock } = paths(dir);
99
+ for (let attempt = 0; attempt < 2; attempt += 1) {
100
+ try {
101
+ const handle = openSync(lock, 'wx', 0o600);
102
+ writeSync(handle, String(process.pid));
103
+ closeSync(handle);
104
+ try {
105
+ return run();
106
+ } finally {
107
+ unlinkSync(lock);
108
+ }
109
+ } catch (error) {
110
+ if (error.code !== 'EEXIST') throw error;
111
+ const holder = Number(readFileSync(lock, 'utf8'));
112
+ if (!processAlive(holder)) {
113
+ unlinkSync(lock);
114
+ continue;
115
+ }
116
+ throw new Error(`another attesso process (pid ${holder}) holds the ledger; retry when it finishes`);
117
+ }
118
+ }
119
+ throw new Error('could not take the ledger lock');
120
+ }
121
+
122
+ export function append(dir, proposal, { now = new Date() } = {}) {
123
+ return withLock(dir, () => {
124
+ const file = paths(dir);
125
+ const key = readJson(file.key);
126
+ const config = readJson(file.config);
127
+ const policy = validatePolicy(readJson(file.policy));
128
+ const rules = existsSync(file.rules) ? validateRules(readJson(file.rules)) : null;
129
+ const history = readEntries(dir).filter((entry) => (entry.task ?? null) === (proposal.task ?? null));
130
+ const policyVerdict = evaluate(policy, proposal, now);
131
+ const pathVerdict = policyVerdict.decision === 'ALLOW'
132
+ ? evaluatePath(rules, history, proposal, now)
133
+ : null;
134
+ const verdict = pathVerdict ?? policyVerdict;
135
+ const meta = pathVerdict?.tainted_by === undefined
136
+ ? proposal.meta
137
+ : { ...(proposal.meta ?? {}), rule: pathVerdict.rule, tainted_by: pathVerdict.tainted_by };
138
+ const seq = config.head.count + 1;
139
+ const entry = buildEntry({
140
+ seq,
141
+ prev: config.head.hash,
142
+ action: proposal.action,
143
+ subject: proposal.subject,
144
+ amount: proposal.amount,
145
+ meta,
146
+ task: proposal.task,
147
+ trust: proposal.trust,
148
+ capability: proposal.capability,
149
+ decision: verdict.decision,
150
+ code: verdict.code,
151
+ policyDigest: policyDigest(policy),
152
+ kid: key.kid,
153
+ at: now.toISOString(),
154
+ });
155
+ entry.sig = signEntry(entry, key.private);
156
+ const handle = openSync(file.ledger, 'a', 0o600);
157
+ try {
158
+ writeSync(handle, `${JSON.stringify(entry)}\n`);
159
+ } finally {
160
+ closeSync(handle);
161
+ }
162
+ writeJson(file.config, { version: 1, head: { count: seq, hash: entryHash(entry) } });
163
+ return entry;
164
+ });
165
+ }