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 +72 -12
- package/bin/attesso.mjs +125 -22
- package/package.json +3 -2
- package/src/ledger.mjs +9 -4
- package/src/policy.mjs +34 -20
- package/src/report.mjs +76 -0
- package/src/rules.mjs +65 -0
- package/src/store.mjs +28 -5
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
|
|
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
|
|
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":"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
17
|
-
attesso log --action A --amount 12.50 --currency EUR
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
66
|
-
|
|
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(
|
|
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
|
|
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
|
|
102
|
-
|
|
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] =
|
|
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
|
|
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.
|
|
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,
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
// local refusal reads the same as a server one
|
|
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 (
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
45
|
-
if (!Array.isArray(policy.
|
|
46
|
-
fail('
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
73
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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),
|