attesso 0.0.1 → 0.3.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/README.md CHANGED
@@ -1,31 +1,51 @@
1
1
  # attesso
2
2
 
3
- A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. No account, no server, no dependencies.
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
4
 
5
5
  ```sh
6
6
  npx attesso init
7
- npx attesso log --action purchase.execute --amount 12.50 --currency EUR --payee shop.example
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
8
9
  npx attesso verify
9
10
  npx attesso show
11
+ npx attesso report
10
12
  ```
11
13
 
12
- `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 and why.
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.
13
29
 
14
30
  ## What it records
15
31
 
16
32
  One signed line per proposed action:
17
33
 
18
34
  ```json
19
- {"v":1,"seq":1,"at":"2026-10-31T12:00:00.000Z","action":"purchase.execute",
20
- "amount":{"minor":1250,"currency":"EUR"},"payee":"shop.example",
21
- "decision":"ALLOW","code":"ALLOW","policy_digest":"…","kid":"…","prev":"genesis","sig":"…"}
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":"…"}
22
38
  ```
23
39
 
24
- Refusal codes match the hosted engine: `ACTION_MISMATCH`, `ABOVE_MAXIMUM`, `VALUE_NOT_ALLOWED`, `VALUE_MISMATCH`, `OUTSIDE_DATE_RANGE`, `REQUIRED_ATTRIBUTE_MISSING`.
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`.
25
41
 
26
42
  ## The policy
27
43
 
28
- `.attesso/policy.json` starts at zero, so nothing is allowed until you set it:
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
+ ```
29
49
 
30
50
  ```json
31
51
  {
@@ -33,21 +53,61 @@ Refusal codes match the hosted engine: `ACTION_MISMATCH`, `ABOVE_MAXIMUM`, `VALU
33
53
  "action": "purchase.execute",
34
54
  "currency": "EUR",
35
55
  "max_amount": 25000,
36
- "payees": ["shop.example"],
56
+ "subjects": ["shop.example"],
37
57
  "on_or_after": "2026-11-01T00:00:00Z",
38
58
  "on_or_before": "2026-12-01T00:00:00Z"
39
59
  }
40
60
  ```
41
61
 
42
- Amounts are whole minor units in the policy (25000 = 250.00 EUR); `log --amount` takes the decimal form.
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.
43
103
 
44
104
  ## What this proves, and what it does not
45
105
 
46
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.
47
107
 
48
- 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 also cannot see absence: a process that deletes the whole directory leaves nothing to verify.
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.
49
109
 
50
- 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 shape, so it can be submitted there later.
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.
51
111
 
52
112
  ## Exit codes
53
113
 
package/bin/attesso.mjs CHANGED
@@ -2,22 +2,38 @@
2
2
  // attesso: a local, signed ledger of what your agents attempted.
3
3
  //
4
4
  // attesso init
5
- // attesso log --action purchase.execute --amount 12.50 --currency EUR --payee shop.example
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
6
7
  // attesso verify
7
8
  // attesso show
8
9
  import { verifyChain } from './../src/ledger.mjs';
9
10
  import { formatMinor, toMinor } from './../src/policy.mjs';
10
11
  import { append, init, isInitialised, paths, readState } from './../src/store.mjs';
12
+ import { buildReport, renderReport } from './../src/report.mjs';
11
13
 
12
- const DIR = '.attesso';
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);
13
18
 
14
19
  const USAGE = `attesso: a local, signed ledger of what your agents attempted
15
20
 
16
- attesso init create .attesso with a key and a policy
17
- attesso log --action A --amount 12.50 --currency EUR [--payee P] [--json]
18
- evaluate one proposed action and record it
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
19
27
  attesso verify recompute the chain and report the first defect
20
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).
21
37
 
22
38
  The log is signed with a key that lives in .attesso/key.json. That proves the
23
39
  ledger was not edited; it does not prove a human authorized anything. Hosted
@@ -34,12 +50,28 @@ function flags(argv) {
34
50
  out[name] = true;
35
51
  continue;
36
52
  }
37
- out[name] = next;
53
+ if (name === 'meta') {
54
+ out.meta = [...(out.meta ?? []), next];
55
+ } else {
56
+ out[name] = next;
57
+ }
38
58
  index += 1;
39
59
  }
40
60
  return out;
41
61
  }
42
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
+
43
75
  function requireLedger() {
44
76
  if (!isInitialised(DIR)) {
45
77
  console.error(`no ledger at ${paths(DIR).dir}; run attesso init first`);
@@ -47,24 +79,63 @@ function requireLedger() {
47
79
  }
48
80
  }
49
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
+
50
88
  function log(argv) {
51
89
  requireLedger();
52
90
  const options = flags(argv);
53
- for (const required of ['action', 'amount', 'currency']) {
54
- if (typeof options[required] !== 'string') {
55
- console.error(`attesso log needs --${required}`);
56
- process.exit(2);
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);
57
106
  }
58
107
  }
59
- const amount = { minor: toMinor(options.amount, options.currency), currency: options.currency };
60
- const entry = append(DIR, { action: options.action, amount, payee: options.payee });
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
+ });
61
133
  if (options.json) {
62
134
  console.log(JSON.stringify(entry));
63
135
  return;
64
136
  }
65
- const human = `${formatMinor(entry.amount.minor, entry.amount.currency)} ${entry.amount.currency}`;
66
- const subject = entry.payee ? ` at ${entry.payee}` : '';
67
- console.log(`#${entry.seq} ${entry.decision} ${entry.action} ${human}${subject} (${entry.code})`);
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})`);
68
139
  process.exitCode = entry.decision === 'ALLOW' ? 0 : 1;
69
140
  }
70
141
 
@@ -79,6 +150,17 @@ function verify() {
79
150
  console.log(`ledger verifies: ${result.reason}, key ${state.key.kid}`);
80
151
  }
81
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
+
82
164
  function show() {
83
165
  requireLedger();
84
166
  const { entries, key } = readState(DIR);
@@ -92,25 +174,36 @@ function show() {
92
174
  counts.set(label, (counts.get(label) ?? 0) + 1);
93
175
  }
94
176
  const allowed = entries.filter((entry) => entry.decision === 'ALLOW').length;
95
- console.log(`${entries.length} decisions: ${allowed} allowed, ${entries.length - allowed} refused (key ${key.kid})`);
177
+ console.log(
178
+ `${entries.length} decisions: ${allowed} allowed, ${entries.length - allowed} refused${totals(entries)} (key ${key.kid})`,
179
+ );
96
180
  for (const [label, count] of [...counts.entries()].sort()) {
97
181
  console.log(` ${String(count).padStart(4)} ${label}`);
98
182
  }
99
- const last = entries.slice(-5);
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
+ }
100
194
  console.log('latest:');
101
- for (const entry of last) {
102
- const human = `${formatMinor(entry.amount.minor, entry.amount.currency)} ${entry.amount.currency}`;
103
- console.log(` #${entry.seq} ${entry.at} ${entry.decision} ${entry.action} ${human} (${entry.code})`);
195
+ for (const entry of entries.slice(-5)) {
196
+ console.log(` #${entry.seq} ${entry.at} ${entry.decision} ${describe(entry)} (${entry.code})`);
104
197
  }
105
198
  }
106
199
 
107
- const [command, ...rest] = process.argv.slice(2);
200
+ const [command, ...rest] = argv;
108
201
  switch (command) {
109
202
  case 'init': {
110
203
  try {
111
204
  const result = init(DIR);
112
205
  console.log(`created ${result.dir} with key ${result.kid}`);
113
- console.log('edit .attesso/policy.json to set what is allowed (it starts at zero)');
206
+ console.log('edit .attesso/policy.json to set what is allowed (it starts at nothing)');
114
207
  } catch (error) {
115
208
  console.error(error.message);
116
209
  process.exit(1);
@@ -126,6 +219,16 @@ switch (command) {
126
219
  case 'show':
127
220
  show();
128
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
+ }
129
232
  case undefined:
130
233
  console.log(USAGE);
131
234
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.0.1",
3
+ "version": "0.3.1",
4
4
  "description": "A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,7 +16,8 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node --test test"
19
+ "test": "node --test test/*.test.mjs",
20
+ "prepublishOnly": "node --test test/*.test.mjs"
20
21
  },
21
22
  "keywords": [
22
23
  "ai-agents",
package/src/ledger.mjs CHANGED
@@ -39,20 +39,25 @@ export function verifyEntry(entry, publicPem) {
39
39
  return verify(null, signingPayload(entry), publicPem, Buffer.from(entry.sig, 'base64url'));
40
40
  }
41
41
 
42
- export function buildEntry({ seq, prev, action, amount, payee, decision, code, policyDigest, kid, at }) {
43
- return {
42
+ export function buildEntry({ seq, prev, action, subject, amount, meta, task, trust, capability, decision, code, policyDigest, kid, at }) {
43
+ const entry = {
44
44
  v: ENTRY_VERSION,
45
45
  seq,
46
46
  at: at ?? new Date().toISOString(),
47
47
  action,
48
- amount,
49
- payee: payee ?? null,
48
+ subject: subject ?? null,
50
49
  decision,
51
50
  code,
52
51
  policy_digest: policyDigest,
53
52
  kid,
54
53
  prev,
55
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;
56
61
  }
57
62
 
58
63
  // Returns the first defect rather than a boolean, so a reader learns which entry
package/src/policy.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // Deterministic local policy. Same code names the hosted engine returns, so a
2
- // local refusal reads the same as a server one when the log is exported.
3
- import { canonical } from './canonical.mjs';
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.
4
3
  import { createHash } from 'node:crypto';
4
+ import { canonical } from './canonical.mjs';
5
5
 
6
6
  export const ALLOW = 'ALLOW';
7
7
 
@@ -30,6 +30,8 @@ export function policyDigest(policy) {
30
30
  return createHash('sha256').update(canonical(policy)).digest('base64url');
31
31
  }
32
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.
33
35
  export function validatePolicy(policy) {
34
36
  const fail = (message) => {
35
37
  throw new Error(`policy is invalid: ${message}`);
@@ -37,13 +39,18 @@ export function validatePolicy(policy) {
37
39
  if (policy === null || typeof policy !== 'object') fail('not an object');
38
40
  if (policy.version !== 1) fail('version must be 1');
39
41
  if (typeof policy.action !== 'string' || policy.action === '') fail('action must be a non-empty string');
40
- if (!EXPONENTS[policy.currency]) fail(`currency ${policy.currency} is not supported`);
41
- if (!Number.isInteger(policy.max_amount) || policy.max_amount < 0) {
42
- fail('max_amount must be a whole number of minor units, 0 or more');
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
+ }
43
50
  }
44
- if (policy.payees !== undefined) {
45
- if (!Array.isArray(policy.payees) || policy.payees.some((payee) => typeof payee !== 'string' || payee === '')) {
46
- fail('payees must be a list of non-empty strings when present');
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');
47
54
  }
48
55
  }
49
56
  for (const field of ['on_or_after', 'on_or_before']) {
@@ -57,20 +64,27 @@ export function validatePolicy(policy) {
57
64
  // Order matters for the reader: the most specific reason wins, so a refusal
58
65
  // names one cause rather than a list.
59
66
  export function evaluate(policy, proposal, now = new Date()) {
60
- if (proposal.action !== policy.action) {
67
+ if (policy.action !== '*' && proposal.action !== policy.action) {
61
68
  return { decision: 'DENY', code: 'ACTION_MISMATCH' };
62
69
  }
63
- if (!proposal.amount || !Number.isInteger(proposal.amount.minor)) {
64
- return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING' };
65
- }
66
- if (proposal.amount.currency !== policy.currency) {
67
- return { decision: 'DENY', code: 'VALUE_MISMATCH' };
68
- }
69
- if (policy.payees && !policy.payees.includes(proposal.payee)) {
70
- return { decision: 'DENY', code: 'VALUE_NOT_ALLOWED' };
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
+ }
71
77
  }
72
- if (proposal.amount.minor > policy.max_amount) {
73
- return { decision: 'DENY', code: 'ABOVE_MAXIMUM' };
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
+ }
74
88
  }
75
89
  const at = now.getTime();
76
90
  if (policy.on_or_after && at < Date.parse(policy.on_or_after)) {
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 CHANGED
@@ -9,12 +9,14 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, u
9
9
  import { join } from 'node:path';
10
10
  import { buildEntry, entryHash, newSigningKey, signEntry } from './ledger.mjs';
11
11
  import { evaluate, policyDigest, validatePolicy } from './policy.mjs';
12
+ import { evaluatePath, rulesDigest, validateRules } from './rules.mjs';
12
13
 
13
14
  export function paths(dir) {
14
15
  return {
15
16
  dir,
16
17
  key: join(dir, 'key.json'),
17
18
  policy: join(dir, 'policy.json'),
19
+ rules: join(dir, 'rules.json'),
18
20
  ledger: join(dir, 'ledger.jsonl'),
19
21
  config: join(dir, 'config.json'),
20
22
  lock: join(dir, 'lock'),
@@ -41,13 +43,20 @@ export function init(dir) {
41
43
  const file = paths(dir);
42
44
  const key = newSigningKey();
43
45
  writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
46
+ // Record everything by default and let rules.json decide: a policy that names
47
+ // one action refuses every other action, so a fresh install could not log the
48
+ // very commands its own README shows.
44
49
  writeJson(file.policy, {
45
50
  version: 1,
46
- action: 'EXAMPLE.action',
47
- currency: 'EUR',
48
- max_amount: 0,
51
+ action: '*',
49
52
  });
50
53
  writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
54
+ writeJson(file.rules, {
55
+ version: 1,
56
+ rules: [
57
+ { id: 'no-send-after-untrusted-read', deny: { capabilities: ['send', 'execute'] }, if_path: { trust: 'untrusted', capability: 'read' } },
58
+ ],
59
+ });
51
60
  writeFileSync(file.ledger, '', { mode: 0o600 });
52
61
  return { dir, kid: key.kid };
53
62
  }
@@ -58,6 +67,7 @@ export function readState(dir) {
58
67
  key: readJson(file.key),
59
68
  config: readJson(file.config),
60
69
  policy: readJson(file.policy),
70
+ rules: existsSync(file.rules) ? readJson(file.rules) : null,
61
71
  entries: readEntries(dir),
62
72
  };
63
73
  }
@@ -117,14 +127,27 @@ export function append(dir, proposal, { now = new Date() } = {}) {
117
127
  const key = readJson(file.key);
118
128
  const config = readJson(file.config);
119
129
  const policy = validatePolicy(readJson(file.policy));
120
- const verdict = evaluate(policy, proposal, now);
130
+ const rules = existsSync(file.rules) ? validateRules(readJson(file.rules)) : null;
131
+ const history = readEntries(dir).filter((entry) => (entry.task ?? null) === (proposal.task ?? null));
132
+ const policyVerdict = evaluate(policy, proposal, now);
133
+ const pathVerdict = policyVerdict.decision === 'ALLOW'
134
+ ? evaluatePath(rules, history, proposal, now)
135
+ : null;
136
+ const verdict = pathVerdict ?? policyVerdict;
137
+ const meta = pathVerdict?.tainted_by === undefined
138
+ ? proposal.meta
139
+ : { ...(proposal.meta ?? {}), rule: pathVerdict.rule, tainted_by: pathVerdict.tainted_by };
121
140
  const seq = config.head.count + 1;
122
141
  const entry = buildEntry({
123
142
  seq,
124
143
  prev: config.head.hash,
125
144
  action: proposal.action,
145
+ subject: proposal.subject,
126
146
  amount: proposal.amount,
127
- payee: proposal.payee,
147
+ meta,
148
+ task: proposal.task,
149
+ trust: proposal.trust,
150
+ capability: proposal.capability,
128
151
  decision: verdict.decision,
129
152
  code: verdict.code,
130
153
  policyDigest: policyDigest(policy),