attesso 0.0.0-stage → 0.0.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 +57 -2
- package/bin/attesso.mjs +140 -0
- package/package.json +31 -4
- package/src/canonical.mjs +8 -0
- package/src/ledger.mjs +82 -0
- package/src/policy.mjs +83 -0
- package/src/store.mjs +144 -0
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# attesso
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. No account, no server, no dependencies.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx attesso init
|
|
7
|
+
npx attesso log --action purchase.execute --amount 12.50 --currency EUR --payee shop.example
|
|
8
|
+
npx attesso verify
|
|
9
|
+
npx attesso show
|
|
10
|
+
```
|
|
11
|
+
|
|
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.
|
|
13
|
+
|
|
14
|
+
## What it records
|
|
15
|
+
|
|
16
|
+
One signed line per proposed action:
|
|
17
|
+
|
|
18
|
+
```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":"…"}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Refusal codes match the hosted engine: `ACTION_MISMATCH`, `ABOVE_MAXIMUM`, `VALUE_NOT_ALLOWED`, `VALUE_MISMATCH`, `OUTSIDE_DATE_RANGE`, `REQUIRED_ATTRIBUTE_MISSING`.
|
|
25
|
+
|
|
26
|
+
## The policy
|
|
27
|
+
|
|
28
|
+
`.attesso/policy.json` starts at zero, so nothing is allowed until you set it:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"version": 1,
|
|
33
|
+
"action": "purchase.execute",
|
|
34
|
+
"currency": "EUR",
|
|
35
|
+
"max_amount": 25000,
|
|
36
|
+
"payees": ["shop.example"],
|
|
37
|
+
"on_or_after": "2026-11-01T00:00:00Z",
|
|
38
|
+
"on_or_before": "2026-12-01T00:00:00Z"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Amounts are whole minor units in the policy (25000 = 250.00 EUR); `log --amount` takes the decimal form.
|
|
43
|
+
|
|
44
|
+
## What this proves, and what it does not
|
|
45
|
+
|
|
46
|
+
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
|
+
|
|
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.
|
|
49
|
+
|
|
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.
|
|
51
|
+
|
|
52
|
+
## Exit codes
|
|
53
|
+
|
|
54
|
+
`0` allowed or verified, `1` refused or a failed verification, `2` usage error.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
Apache-2.0.
|
package/bin/attesso.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// attesso: a local, signed ledger of what your agents attempted.
|
|
3
|
+
//
|
|
4
|
+
// attesso init
|
|
5
|
+
// attesso log --action purchase.execute --amount 12.50 --currency EUR --payee shop.example
|
|
6
|
+
// attesso verify
|
|
7
|
+
// attesso show
|
|
8
|
+
import { verifyChain } from './../src/ledger.mjs';
|
|
9
|
+
import { formatMinor, toMinor } from './../src/policy.mjs';
|
|
10
|
+
import { append, init, isInitialised, paths, readState } from './../src/store.mjs';
|
|
11
|
+
|
|
12
|
+
const DIR = '.attesso';
|
|
13
|
+
|
|
14
|
+
const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
15
|
+
|
|
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
|
|
19
|
+
attesso verify recompute the chain and report the first defect
|
|
20
|
+
attesso show what was allowed, what was refused, and why
|
|
21
|
+
|
|
22
|
+
The log is signed with a key that lives in .attesso/key.json. That proves the
|
|
23
|
+
ledger was not edited; it does not prove a human authorized anything. Hosted
|
|
24
|
+
Attesso adds passkey approval and evidence a third party verifies offline.`;
|
|
25
|
+
|
|
26
|
+
function flags(argv) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
+
const token = argv[index];
|
|
30
|
+
if (!token.startsWith('--')) continue;
|
|
31
|
+
const name = token.slice(2);
|
|
32
|
+
const next = argv[index + 1];
|
|
33
|
+
if (next === undefined || next.startsWith('--')) {
|
|
34
|
+
out[name] = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out[name] = next;
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireLedger() {
|
|
44
|
+
if (!isInitialised(DIR)) {
|
|
45
|
+
console.error(`no ledger at ${paths(DIR).dir}; run attesso init first`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function log(argv) {
|
|
51
|
+
requireLedger();
|
|
52
|
+
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);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const amount = { minor: toMinor(options.amount, options.currency), currency: options.currency };
|
|
60
|
+
const entry = append(DIR, { action: options.action, amount, payee: options.payee });
|
|
61
|
+
if (options.json) {
|
|
62
|
+
console.log(JSON.stringify(entry));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
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})`);
|
|
68
|
+
process.exitCode = entry.decision === 'ALLOW' ? 0 : 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verify() {
|
|
72
|
+
requireLedger();
|
|
73
|
+
const state = readState(DIR);
|
|
74
|
+
const result = verifyChain(state.entries, state.key.public, state.config.head);
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
console.error(`ledger does not verify: ${result.reason}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
console.log(`ledger verifies: ${result.reason}, key ${state.key.kid}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function show() {
|
|
83
|
+
requireLedger();
|
|
84
|
+
const { entries, key } = readState(DIR);
|
|
85
|
+
if (entries.length === 0) {
|
|
86
|
+
console.log('no decisions yet');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const counts = new Map();
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const label = `${entry.decision} ${entry.code}`;
|
|
92
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
const allowed = entries.filter((entry) => entry.decision === 'ALLOW').length;
|
|
95
|
+
console.log(`${entries.length} decisions: ${allowed} allowed, ${entries.length - allowed} refused (key ${key.kid})`);
|
|
96
|
+
for (const [label, count] of [...counts.entries()].sort()) {
|
|
97
|
+
console.log(` ${String(count).padStart(4)} ${label}`);
|
|
98
|
+
}
|
|
99
|
+
const last = entries.slice(-5);
|
|
100
|
+
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})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
108
|
+
switch (command) {
|
|
109
|
+
case 'init': {
|
|
110
|
+
try {
|
|
111
|
+
const result = init(DIR);
|
|
112
|
+
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)');
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(error.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'log':
|
|
121
|
+
log(rest);
|
|
122
|
+
break;
|
|
123
|
+
case 'verify':
|
|
124
|
+
verify();
|
|
125
|
+
break;
|
|
126
|
+
case 'show':
|
|
127
|
+
show();
|
|
128
|
+
break;
|
|
129
|
+
case undefined:
|
|
130
|
+
console.log(USAGE);
|
|
131
|
+
break;
|
|
132
|
+
default:
|
|
133
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
134
|
+
console.log(USAGE);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
console.error(`unknown command ${command}\n`);
|
|
138
|
+
console.log(USAGE);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attesso",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
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"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"agent-governance",
|
|
24
|
+
"audit",
|
|
25
|
+
"ledger",
|
|
26
|
+
"guardrails"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://codeberg.org/douwe/attesso.git",
|
|
31
|
+
"directory": "packages/attesso"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,82 @@
|
|
|
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, amount, payee, decision, code, policyDigest, kid, at }) {
|
|
43
|
+
return {
|
|
44
|
+
v: ENTRY_VERSION,
|
|
45
|
+
seq,
|
|
46
|
+
at: at ?? new Date().toISOString(),
|
|
47
|
+
action,
|
|
48
|
+
amount,
|
|
49
|
+
payee: payee ?? null,
|
|
50
|
+
decision,
|
|
51
|
+
code,
|
|
52
|
+
policy_digest: policyDigest,
|
|
53
|
+
kid,
|
|
54
|
+
prev,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Returns the first defect rather than a boolean, so a reader learns which entry
|
|
59
|
+
// to look at. `head` comes from config, which is what makes truncation visible.
|
|
60
|
+
export function verifyChain(entries, publicPem, head) {
|
|
61
|
+
let previous = 'genesis';
|
|
62
|
+
for (const [index, entry] of entries.entries()) {
|
|
63
|
+
if (entry.seq !== index + 1) {
|
|
64
|
+
return { ok: false, reason: `entry ${index + 1} carries seq ${entry.seq}` };
|
|
65
|
+
}
|
|
66
|
+
if (entry.prev !== previous) {
|
|
67
|
+
return { ok: false, reason: `entry ${entry.seq} does not chain to the entry before it` };
|
|
68
|
+
}
|
|
69
|
+
if (!verifyEntry(entry, publicPem)) {
|
|
70
|
+
return { ok: false, reason: `entry ${entry.seq} signature does not verify` };
|
|
71
|
+
}
|
|
72
|
+
previous = entryHash(entry);
|
|
73
|
+
}
|
|
74
|
+
if (!head) return { ok: false, reason: 'no recorded head, so the end of the log cannot be checked' };
|
|
75
|
+
if (head.count !== entries.length) {
|
|
76
|
+
return { ok: false, reason: `config records ${head.count} entries, the log holds ${entries.length}` };
|
|
77
|
+
}
|
|
78
|
+
if (head.hash !== previous) {
|
|
79
|
+
return { ok: false, reason: 'the last entry does not match the recorded head' };
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, reason: `chain of ${entries.length} entries verifies` };
|
|
82
|
+
}
|
package/src/policy.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
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
|
+
export function validatePolicy(policy) {
|
|
34
|
+
const fail = (message) => {
|
|
35
|
+
throw new Error(`policy is invalid: ${message}`);
|
|
36
|
+
};
|
|
37
|
+
if (policy === null || typeof policy !== 'object') fail('not an object');
|
|
38
|
+
if (policy.version !== 1) fail('version must be 1');
|
|
39
|
+
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');
|
|
43
|
+
}
|
|
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');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const field of ['on_or_after', 'on_or_before']) {
|
|
50
|
+
if (policy[field] !== undefined && Number.isNaN(Date.parse(policy[field]))) {
|
|
51
|
+
fail(`${field} must be an ISO date when present`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return policy;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Order matters for the reader: the most specific reason wins, so a refusal
|
|
58
|
+
// names one cause rather than a list.
|
|
59
|
+
export function evaluate(policy, proposal, now = new Date()) {
|
|
60
|
+
if (proposal.action !== policy.action) {
|
|
61
|
+
return { decision: 'DENY', code: 'ACTION_MISMATCH' };
|
|
62
|
+
}
|
|
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' };
|
|
71
|
+
}
|
|
72
|
+
if (proposal.amount.minor > policy.max_amount) {
|
|
73
|
+
return { decision: 'DENY', code: 'ABOVE_MAXIMUM' };
|
|
74
|
+
}
|
|
75
|
+
const at = now.getTime();
|
|
76
|
+
if (policy.on_or_after && at < Date.parse(policy.on_or_after)) {
|
|
77
|
+
return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
|
|
78
|
+
}
|
|
79
|
+
if (policy.on_or_before && at > Date.parse(policy.on_or_before)) {
|
|
80
|
+
return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
|
|
81
|
+
}
|
|
82
|
+
return { decision: ALLOW, code: ALLOW };
|
|
83
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
|
|
13
|
+
export function paths(dir) {
|
|
14
|
+
return {
|
|
15
|
+
dir,
|
|
16
|
+
key: join(dir, 'key.json'),
|
|
17
|
+
policy: join(dir, 'policy.json'),
|
|
18
|
+
ledger: join(dir, 'ledger.jsonl'),
|
|
19
|
+
config: join(dir, 'config.json'),
|
|
20
|
+
lock: join(dir, 'lock'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
|
|
26
|
+
function writeJson(path, value) {
|
|
27
|
+
const temporary = `${path}.tmp`;
|
|
28
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
29
|
+
renameSync(temporary, path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isInitialised(dir) {
|
|
33
|
+
return existsSync(paths(dir).key);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function init(dir) {
|
|
37
|
+
if (isInitialised(dir)) {
|
|
38
|
+
throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
|
|
39
|
+
}
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const file = paths(dir);
|
|
42
|
+
const key = newSigningKey();
|
|
43
|
+
writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
|
|
44
|
+
writeJson(file.policy, {
|
|
45
|
+
version: 1,
|
|
46
|
+
action: 'EXAMPLE.action',
|
|
47
|
+
currency: 'EUR',
|
|
48
|
+
max_amount: 0,
|
|
49
|
+
});
|
|
50
|
+
writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
|
|
51
|
+
writeFileSync(file.ledger, '', { mode: 0o600 });
|
|
52
|
+
return { dir, kid: key.kid };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readState(dir) {
|
|
56
|
+
const file = paths(dir);
|
|
57
|
+
return {
|
|
58
|
+
key: readJson(file.key),
|
|
59
|
+
config: readJson(file.config),
|
|
60
|
+
policy: readJson(file.policy),
|
|
61
|
+
entries: readEntries(dir),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readEntries(dir) {
|
|
66
|
+
const text = readFileSync(paths(dir).ledger, 'utf8');
|
|
67
|
+
if (text === '') return [];
|
|
68
|
+
return text
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter((line) => line !== '')
|
|
71
|
+
.map((line, index) => {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(line);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(`ledger line ${index + 1} is not JSON; the file was edited or truncated mid-line`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function processAlive(pid) {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function withLock(dir, run) {
|
|
90
|
+
const { lock } = paths(dir);
|
|
91
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
92
|
+
try {
|
|
93
|
+
const handle = openSync(lock, 'wx', 0o600);
|
|
94
|
+
writeSync(handle, String(process.pid));
|
|
95
|
+
closeSync(handle);
|
|
96
|
+
try {
|
|
97
|
+
return run();
|
|
98
|
+
} finally {
|
|
99
|
+
unlinkSync(lock);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code !== 'EEXIST') throw error;
|
|
103
|
+
const holder = Number(readFileSync(lock, 'utf8'));
|
|
104
|
+
if (!processAlive(holder)) {
|
|
105
|
+
unlinkSync(lock);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`another attesso process (pid ${holder}) holds the ledger; retry when it finishes`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error('could not take the ledger lock');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function append(dir, proposal, { now = new Date() } = {}) {
|
|
115
|
+
return withLock(dir, () => {
|
|
116
|
+
const file = paths(dir);
|
|
117
|
+
const key = readJson(file.key);
|
|
118
|
+
const config = readJson(file.config);
|
|
119
|
+
const policy = validatePolicy(readJson(file.policy));
|
|
120
|
+
const verdict = evaluate(policy, proposal, now);
|
|
121
|
+
const seq = config.head.count + 1;
|
|
122
|
+
const entry = buildEntry({
|
|
123
|
+
seq,
|
|
124
|
+
prev: config.head.hash,
|
|
125
|
+
action: proposal.action,
|
|
126
|
+
amount: proposal.amount,
|
|
127
|
+
payee: proposal.payee,
|
|
128
|
+
decision: verdict.decision,
|
|
129
|
+
code: verdict.code,
|
|
130
|
+
policyDigest: policyDigest(policy),
|
|
131
|
+
kid: key.kid,
|
|
132
|
+
at: now.toISOString(),
|
|
133
|
+
});
|
|
134
|
+
entry.sig = signEntry(entry, key.private);
|
|
135
|
+
const handle = openSync(file.ledger, 'a', 0o600);
|
|
136
|
+
try {
|
|
137
|
+
writeSync(handle, `${JSON.stringify(entry)}\n`);
|
|
138
|
+
} finally {
|
|
139
|
+
closeSync(handle);
|
|
140
|
+
}
|
|
141
|
+
writeJson(file.config, { version: 1, head: { count: seq, hash: entryHash(entry) } });
|
|
142
|
+
return entry;
|
|
143
|
+
});
|
|
144
|
+
}
|