attesso 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +172 -19
- package/bin/attesso-control-mcp.mjs +13 -0
- package/bin/attesso-mcp.mjs +2 -0
- package/bin/attesso.mjs +600 -65
- package/package.json +8 -4
- package/src/bundle.mjs +128 -0
- package/src/classify.mjs +83 -0
- package/src/cli-io.mjs +130 -0
- package/src/connect.mjs +160 -0
- package/src/control.mjs +101 -0
- package/src/decide.mjs +10 -0
- package/src/fs-paths.mjs +121 -0
- package/src/harness.mjs +130 -0
- package/src/hook.mjs +72 -37
- package/src/install.mjs +122 -0
- package/src/jsonl.mjs +16 -0
- package/src/ledger.mjs +79 -20
- package/src/mcp/control.mjs +171 -0
- package/src/mcp-proxy.mjs +605 -0
- package/src/merkle.mjs +83 -0
- package/src/policy.mjs +8 -8
- package/src/receipt.mjs +104 -0
- package/src/replay.mjs +133 -0
- package/src/report.mjs +69 -27
- package/src/rules.mjs +78 -6
- package/src/serve.mjs +4 -2
- package/src/setup.mjs +131 -0
- package/src/store.mjs +425 -64
- package/src/stream.mjs +72 -0
- package/src/toolpin.mjs +131 -0
- package/src/ui/catalog.mjs +266 -0
- package/src/ui/live.mjs +58 -0
- package/src/ui/query.mjs +163 -0
- package/src/ui/server.mjs +379 -0
- package/src/ui/static/index.html +1695 -0
- package/src/usage.mjs +96 -0
- package/src/watch.mjs +78 -0
package/src/store.mjs
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
// config.json the recorded head (count + hash), the only thing that makes a
|
|
6
6
|
// truncated log visible
|
|
7
7
|
// lock held while appending, so two agents cannot interleave writes
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { channel } from 'node:diagnostics_channel';
|
|
10
|
+
import { closeSync, existsSync, fsyncSync, ftruncateSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { buildEntry, entryHash, newSigningKey, signEntry, verifyEntry, verifyHead, verifyStep } from './ledger.mjs';
|
|
13
|
+
import { DEFAULT_TOOLS, loadTools } from './classify.mjs';
|
|
14
|
+
import { decide } from './decide.mjs';
|
|
15
|
+
import { parseLine, parseTail } from './jsonl.mjs';
|
|
16
|
+
import { policyDigest, validatePolicy } from './policy.mjs';
|
|
17
|
+
import { rulesDigest, rulesForProfile, validateRules } from './rules.mjs';
|
|
18
|
+
import { toolGate, manifestDigest, toolsDigest } from './toolpin.mjs';
|
|
19
|
+
import { evaluatePathScope, validatePathsConfig } from './fs-paths.mjs';
|
|
20
|
+
import { forEachEntry, forEachEntrySync } from './stream.mjs';
|
|
13
21
|
|
|
14
22
|
export function paths(dir) {
|
|
15
23
|
return {
|
|
@@ -17,48 +25,247 @@ export function paths(dir) {
|
|
|
17
25
|
key: join(dir, 'key.json'),
|
|
18
26
|
policy: join(dir, 'policy.json'),
|
|
19
27
|
rules: join(dir, 'rules.json'),
|
|
28
|
+
fsPaths: join(dir, 'paths.json'),
|
|
29
|
+
uiIndex: join(dir, 'ui-index.json'),
|
|
20
30
|
ledger: join(dir, 'ledger.jsonl'),
|
|
21
31
|
config: join(dir, 'config.json'),
|
|
22
32
|
lock: join(dir, 'lock'),
|
|
33
|
+
policies: join(dir, 'policies'),
|
|
34
|
+
tools: join(dir, 'tools.json'),
|
|
35
|
+
toolLock: join(dir, 'tools.lock.json'),
|
|
36
|
+
toolsSnapshots: join(dir, 'tools'),
|
|
37
|
+
mcpSnapshots: join(dir, 'mcp'),
|
|
23
38
|
};
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
|
|
27
42
|
|
|
43
|
+
// In-process monitoring: no dependency, no output, no cost when nobody listens.
|
|
44
|
+
const decisionChannel = channel('bounds.decision');
|
|
45
|
+
|
|
46
|
+
function publishDecision(entry) {
|
|
47
|
+
if (!decisionChannel.hasSubscribers) return;
|
|
48
|
+
try {
|
|
49
|
+
decisionChannel.publish({
|
|
50
|
+
seq: entry.seq,
|
|
51
|
+
at: entry.at,
|
|
52
|
+
action: entry.action,
|
|
53
|
+
decision: entry.decision,
|
|
54
|
+
code: entry.code,
|
|
55
|
+
rule: entry.rule ?? null,
|
|
56
|
+
capability: entry.capability ?? null,
|
|
57
|
+
trust: entry.trust ?? null,
|
|
58
|
+
args_hash: entry.args_hash ?? null,
|
|
59
|
+
policy_digest: entry.policy_digest,
|
|
60
|
+
rules_digest: entry.rules_digest ?? null,
|
|
61
|
+
tools_digest: entry.tools_digest ?? null,
|
|
62
|
+
tool_pin: entry.tool_pin ?? null,
|
|
63
|
+
mcp_digest: entry.mcp_digest ?? null,
|
|
64
|
+
entry_hash: entryHash(entry),
|
|
65
|
+
kid: entry.kid,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// publish runs subscribers synchronously; a subscriber that throws is a bug
|
|
69
|
+
// in the subscriber, not a reason to fail the decision that was recorded.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
function writeJson(path, value) {
|
|
29
|
-
const temporary = `${path}.tmp`;
|
|
30
|
-
|
|
74
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
75
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
76
|
+
const handle = openSync(temporary, 'w', 0o600);
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(handle, text);
|
|
79
|
+
fsyncSync(handle);
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(handle);
|
|
82
|
+
}
|
|
31
83
|
renameSync(temporary, path);
|
|
84
|
+
fsyncDir(dirname(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeEntry(file, entry) {
|
|
88
|
+
const handle = openSync(file.ledger, 'a', 0o600);
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(handle, `${JSON.stringify(entry)}\n`);
|
|
91
|
+
fsyncSync(handle);
|
|
92
|
+
} finally {
|
|
93
|
+
closeSync(handle);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readAt(fd, buffer, position) {
|
|
98
|
+
let offset = 0;
|
|
99
|
+
while (offset < buffer.length) {
|
|
100
|
+
const bytes = readSync(fd, buffer, offset, buffer.length - offset, position + offset);
|
|
101
|
+
if (bytes === 0) break;
|
|
102
|
+
offset += bytes;
|
|
103
|
+
}
|
|
104
|
+
return offset;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A crash can leave a line without its newline. A parseable line is kept (its
|
|
108
|
+
// signature still decides whether it counts); an unparseable tail was never an
|
|
109
|
+
// entry and is dropped. The head count is untouched, so a real truncation still
|
|
110
|
+
// fails the append instead of being papered over. This reads backwards so an
|
|
111
|
+
// old ledger is not loaded into memory just to repair its last line.
|
|
112
|
+
function repairTornTail(file) {
|
|
113
|
+
const size = statSync(file.ledger).size;
|
|
114
|
+
if (size === 0) return;
|
|
115
|
+
const fd = openSync(file.ledger, 'r+');
|
|
116
|
+
try {
|
|
117
|
+
const last = Buffer.alloc(1);
|
|
118
|
+
readAt(fd, last, size - 1);
|
|
119
|
+
if (last[0] === 0x0a) return;
|
|
120
|
+
|
|
121
|
+
let newline = -1;
|
|
122
|
+
let end = size - 1;
|
|
123
|
+
while (end >= 0 && newline === -1) {
|
|
124
|
+
const start = Math.max(0, end - 64 * 1024 + 1);
|
|
125
|
+
const chunk = Buffer.alloc(end - start + 1);
|
|
126
|
+
readAt(fd, chunk, start);
|
|
127
|
+
const at = chunk.lastIndexOf(0x0a);
|
|
128
|
+
if (at !== -1) newline = start + at;
|
|
129
|
+
end = start - 1;
|
|
130
|
+
}
|
|
131
|
+
const cut = newline + 1;
|
|
132
|
+
const tail = Buffer.alloc(size - cut);
|
|
133
|
+
readAt(fd, tail, cut);
|
|
134
|
+
let complete = false;
|
|
135
|
+
try {
|
|
136
|
+
JSON.parse(tail.toString('utf8'));
|
|
137
|
+
complete = true;
|
|
138
|
+
} catch {
|
|
139
|
+
// The tail is a partial write, not an entry.
|
|
140
|
+
}
|
|
141
|
+
if (complete) {
|
|
142
|
+
const handle = openSync(file.ledger, 'a', 0o600);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(handle, '\n');
|
|
145
|
+
fsyncSync(handle);
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(handle);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
ftruncateSync(fd, cut);
|
|
152
|
+
fsyncSync(fd);
|
|
153
|
+
} finally {
|
|
154
|
+
closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A rename is durable once the directory entry is flushed; Windows cannot open a
|
|
159
|
+
// directory for fsync, so this is best-effort there.
|
|
160
|
+
function fsyncDir(path) {
|
|
161
|
+
let handle;
|
|
162
|
+
try {
|
|
163
|
+
handle = openSync(path, 'r');
|
|
164
|
+
fsyncSync(handle);
|
|
165
|
+
} catch {
|
|
166
|
+
// Directory fsync is unavailable on this platform; the file fsync still ran.
|
|
167
|
+
} finally {
|
|
168
|
+
if (handle !== undefined) closeSync(handle);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A decision names the exact policy and rules bytes that judged it, so a receipt
|
|
173
|
+
// can carry them later. Content addressed, written once, never rewritten.
|
|
174
|
+
const snapshotBase = (dir, kind) => (kind === 'tools' ? paths(dir).toolsSnapshots : paths(dir).policies);
|
|
175
|
+
const snapshotFile = (dir, digest, kind) => join(snapshotBase(dir, kind), `${digest}.json`);
|
|
176
|
+
|
|
177
|
+
function writeSnapshot(dir, digest, value, kind = 'policy') {
|
|
178
|
+
const file = snapshotFile(dir, digest, kind);
|
|
179
|
+
if (existsSync(file)) return;
|
|
180
|
+
mkdirSync(snapshotBase(dir, kind), { recursive: true, mode: 0o700 });
|
|
181
|
+
writeJson(file, value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function readToolLock(dir) {
|
|
185
|
+
const file = paths(dir).toolLock;
|
|
186
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function writeToolLock(dir, lock) {
|
|
190
|
+
return withLock(dir, () => {
|
|
191
|
+
writeJson(paths(dir).toolLock, lock);
|
|
192
|
+
return lock;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function writeMcpManifest(dir, manifest) {
|
|
197
|
+
const digest = manifestDigest(manifest);
|
|
198
|
+
writeSnapshot(dir, digest, manifest, 'mcp');
|
|
199
|
+
return digest;
|
|
32
200
|
}
|
|
33
201
|
|
|
34
202
|
export function isInitialised(dir) {
|
|
35
203
|
return existsSync(paths(dir).key);
|
|
36
204
|
}
|
|
37
205
|
|
|
38
|
-
export function
|
|
206
|
+
export function readKey(dir) {
|
|
207
|
+
return readJson(paths(dir).key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function readConfig(dir) {
|
|
211
|
+
return readJson(paths(dir).config);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function readSnapshot(dir, digest, kind = 'policy') {
|
|
215
|
+
const file = snapshotFile(dir, digest, kind);
|
|
216
|
+
if (!existsSync(file)) throw new Error(`SNAPSHOT_MISSING: ${kind} ${digest} was not archived with this entry`);
|
|
217
|
+
return readJson(file);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function init(dir, { profile = 'balanced' } = {}) {
|
|
39
221
|
if (isInitialised(dir)) {
|
|
40
222
|
throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
|
|
41
223
|
}
|
|
224
|
+
const rules = rulesForProfile(profile);
|
|
42
225
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
226
|
+
return withLock(dir, () => {
|
|
227
|
+
// Two processes that saw an empty directory must not both create a key.
|
|
228
|
+
// The first one wins; the second sees the key under the same lock.
|
|
229
|
+
if (isInitialised(dir)) {
|
|
230
|
+
throw new Error(`${dir} already holds a ledger; refusing to replace its key`);
|
|
231
|
+
}
|
|
232
|
+
const file = paths(dir);
|
|
233
|
+
const key = newSigningKey();
|
|
234
|
+
// Record everything by default and let rules.json decide: a policy that names
|
|
235
|
+
// one action refuses every other action, so a fresh install could not log the
|
|
236
|
+
// very commands its own README shows.
|
|
237
|
+
writeJson(file.policy, {
|
|
238
|
+
version: 1,
|
|
239
|
+
action: '*',
|
|
240
|
+
});
|
|
241
|
+
writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
|
|
242
|
+
writeJson(file.tools, DEFAULT_TOOLS);
|
|
243
|
+
writeJson(file.rules, rules);
|
|
244
|
+
writeFileSync(file.ledger, '', { mode: 0o600 });
|
|
245
|
+
// The key is the readiness marker; writing it last means a crash during init
|
|
246
|
+
// leaves a directory that is still uninitialised, not one that half works.
|
|
247
|
+
writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
|
|
248
|
+
return { dir, kid: key.kid, profile };
|
|
52
249
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// An existing ledger can adopt a shipped profile without touching its key,
|
|
253
|
+
// head or entries. The previous rules stay beside the ledger for rollback.
|
|
254
|
+
export function setRulesProfile(dir, profile = 'balanced') {
|
|
255
|
+
const next = rulesForProfile(profile);
|
|
256
|
+
validateRules(next);
|
|
257
|
+
return withLock(dir, () => {
|
|
258
|
+
const file = paths(dir);
|
|
259
|
+
const previous = existsSync(file.rules) ? readFileSync(file.rules, 'utf8') : null;
|
|
260
|
+
const previousDigest = previous === null ? undefined : rulesDigest(JSON.parse(previous));
|
|
261
|
+
const nextDigest = rulesDigest(next);
|
|
262
|
+
if (previousDigest === nextDigest) {
|
|
263
|
+
return { profile, changed: false, previous: previousDigest, digest: nextDigest };
|
|
264
|
+
}
|
|
265
|
+
if (previous !== null) renameSync(file.rules, `${file.rules}.bak`);
|
|
266
|
+
writeJson(file.rules, next);
|
|
267
|
+
return { profile, changed: true, previous: previousDigest, digest: nextDigest };
|
|
59
268
|
});
|
|
60
|
-
writeFileSync(file.ledger, '', { mode: 0o600 });
|
|
61
|
-
return { dir, kid: key.kid };
|
|
62
269
|
}
|
|
63
270
|
|
|
64
271
|
export function readState(dir) {
|
|
@@ -75,16 +282,43 @@ export function readState(dir) {
|
|
|
75
282
|
export function readEntries(dir) {
|
|
76
283
|
const text = readFileSync(paths(dir).ledger, 'utf8');
|
|
77
284
|
if (text === '') return [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
285
|
+
const lines = text.split('\n');
|
|
286
|
+
const trailing = lines.pop();
|
|
287
|
+
const entries = lines.map((line, index) => parseLine(line, index + 1));
|
|
288
|
+
// A reader can catch an append mid-line. A partial tail is not an entry and
|
|
289
|
+
// is ignored; the recorded head still makes a real truncation visible.
|
|
290
|
+
if (trailing !== '') {
|
|
291
|
+
const entry = parseTail(trailing, lines.length + 1);
|
|
292
|
+
if (entry !== undefined) entries.push(entry);
|
|
293
|
+
}
|
|
294
|
+
return entries;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Stream the same checks verifyChain performs over an array. This is the
|
|
298
|
+
// path that keeps a year-old ledger from being loaded into memory at once.
|
|
299
|
+
export async function verifyLedger(dir) {
|
|
300
|
+
const file = paths(dir);
|
|
301
|
+
let key;
|
|
302
|
+
let head;
|
|
303
|
+
try {
|
|
304
|
+
key = readKey(dir);
|
|
305
|
+
head = readConfig(dir).head;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { ok: false, reason: error.message };
|
|
308
|
+
}
|
|
309
|
+
let previous = 'genesis';
|
|
310
|
+
let count = 0;
|
|
311
|
+
try {
|
|
312
|
+
await forEachEntry(file.ledger, (entry, index) => {
|
|
313
|
+
const step = verifyStep(entry, index - 1, previous, key.public);
|
|
314
|
+
if (!step.ok) throw new Error(step.reason);
|
|
315
|
+
previous = step.hash;
|
|
316
|
+
count = index;
|
|
87
317
|
});
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return { ok: false, reason: error.message, kid: key.kid };
|
|
320
|
+
}
|
|
321
|
+
return { ...verifyHead(count, previous, head), kid: key.kid, count };
|
|
88
322
|
}
|
|
89
323
|
|
|
90
324
|
function processAlive(pid) {
|
|
@@ -96,51 +330,170 @@ function processAlive(pid) {
|
|
|
96
330
|
}
|
|
97
331
|
}
|
|
98
332
|
|
|
333
|
+
// A writer waits for a live holder instead of failing the decision. The process
|
|
334
|
+
// is short-lived, so a synchronous sleep is the simplest correct backoff.
|
|
335
|
+
function sleepSync(ms) {
|
|
336
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const LOCK_WAIT_MS = () => Number(process.env.ATTESSO_LOCK_WAIT_MS ?? 5000);
|
|
340
|
+
const LOCK_RETRY_MS = 25;
|
|
341
|
+
|
|
342
|
+
// Create the lock with its owner already inside, then link it into place. A
|
|
343
|
+
// plain O_EXCL create leaves an empty file for a moment, and a waiter that reads
|
|
344
|
+
// it in that window would reclaim a live lock. The token lets the holder remove
|
|
345
|
+
// only its own lock, never one a later process created after reclaiming it.
|
|
346
|
+
function acquireLock(lock) {
|
|
347
|
+
const token = `${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
348
|
+
const candidate = `${lock}.${token}`;
|
|
349
|
+
const handle = openSync(candidate, 'wx', 0o600);
|
|
350
|
+
try {
|
|
351
|
+
writeFileSync(handle, token);
|
|
352
|
+
fsyncSync(handle);
|
|
353
|
+
} finally {
|
|
354
|
+
closeSync(handle);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
linkSync(candidate, lock);
|
|
358
|
+
return token;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.code === 'EEXIST') return null;
|
|
361
|
+
throw error;
|
|
362
|
+
} finally {
|
|
363
|
+
try {
|
|
364
|
+
unlinkSync(candidate);
|
|
365
|
+
} catch {
|
|
366
|
+
// The candidate was already removed; the lock name is what matters.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
99
371
|
function withLock(dir, run) {
|
|
100
372
|
const { lock } = paths(dir);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
closeSync(handle);
|
|
373
|
+
const deadline = Date.now() + LOCK_WAIT_MS();
|
|
374
|
+
for (;;) {
|
|
375
|
+
const token = acquireLock(lock);
|
|
376
|
+
if (token) {
|
|
106
377
|
try {
|
|
107
378
|
return run();
|
|
108
379
|
} finally {
|
|
109
|
-
|
|
380
|
+
try {
|
|
381
|
+
if (readFileSync(lock, 'utf8').trim() === token) unlinkSync(lock);
|
|
382
|
+
} catch {
|
|
383
|
+
// The lock is already gone; nothing to undo.
|
|
384
|
+
}
|
|
110
385
|
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
386
|
+
}
|
|
387
|
+
let raw = '';
|
|
388
|
+
try {
|
|
389
|
+
raw = readFileSync(lock, 'utf8').trim();
|
|
390
|
+
} catch {
|
|
391
|
+
// The holder released it between link and read; retry immediately.
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const holder = Number(raw.split('.')[0]);
|
|
395
|
+
if (raw === '' || !Number.isInteger(holder) || holder <= 0) {
|
|
396
|
+
throw new Error(`the ledger lock at ${lock} has no owner; remove it if no attesso process is running`);
|
|
397
|
+
}
|
|
398
|
+
if (!processAlive(holder)) {
|
|
399
|
+
// Only unlink the lock we judged stale, not one a new holder just linked.
|
|
400
|
+
let current = '';
|
|
401
|
+
try {
|
|
402
|
+
current = readFileSync(lock, 'utf8').trim();
|
|
403
|
+
} catch {
|
|
116
404
|
continue;
|
|
117
405
|
}
|
|
406
|
+
if (current === raw) {
|
|
407
|
+
try {
|
|
408
|
+
unlinkSync(lock);
|
|
409
|
+
} catch {
|
|
410
|
+
// Someone else reclaimed it first; the next iteration takes it.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (Date.now() >= deadline) {
|
|
118
416
|
throw new Error(`another attesso process (pid ${holder}) holds the ledger; retry when it finishes`);
|
|
119
417
|
}
|
|
418
|
+
sleepSync(LOCK_RETRY_MS + Math.floor(Math.random() * LOCK_RETRY_MS));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function withLedgerLock(dir, run) {
|
|
423
|
+
return withLock(dir, run);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// A crash can leave signed lines whose head update never landed. Verify the
|
|
427
|
+
// tail while collecting only the task history the rules need, so append does
|
|
428
|
+
// not hold the whole ledger in memory.
|
|
429
|
+
function reconcileHead(dir, head, task, publicPem) {
|
|
430
|
+
let count = 0;
|
|
431
|
+
let lastHash = head.hash;
|
|
432
|
+
const history = [];
|
|
433
|
+
forEachEntrySync(paths(dir).ledger, (entry, index) => {
|
|
434
|
+
count = index;
|
|
435
|
+
if (index > head.count) {
|
|
436
|
+
if (entry.seq !== index) throw new Error(`entry ${index} carries seq ${entry.seq}`);
|
|
437
|
+
if (entry.prev !== lastHash) throw new Error(`entry ${entry.seq} does not chain to the entry before it`);
|
|
438
|
+
if (!verifyEntry(entry, publicPem)) throw new Error(`entry ${entry.seq} signature does not verify`);
|
|
439
|
+
lastHash = entryHash(entry);
|
|
440
|
+
}
|
|
441
|
+
if ((entry.task ?? null) === (task ?? null)) history.push(entry);
|
|
442
|
+
});
|
|
443
|
+
if (count < head.count) {
|
|
444
|
+
throw new Error(`config records ${head.count} entries, the log holds ${count}; the ledger was truncated`);
|
|
120
445
|
}
|
|
121
|
-
|
|
446
|
+
if (count === head.count) return { head, history };
|
|
447
|
+
const repaired = { count, hash: lastHash };
|
|
448
|
+
writeJson(paths(dir).config, { version: 1, head: repaired });
|
|
449
|
+
return { head: repaired, history };
|
|
122
450
|
}
|
|
123
451
|
|
|
124
|
-
export function append(dir, proposal, { now = new Date() } = {}) {
|
|
452
|
+
export function append(dir, proposal, { now = new Date(), control } = {}) {
|
|
125
453
|
return withLock(dir, () => {
|
|
126
454
|
const file = paths(dir);
|
|
455
|
+
repairTornTail(file);
|
|
127
456
|
const key = readJson(file.key);
|
|
128
457
|
const config = readJson(file.config);
|
|
129
458
|
const policy = validatePolicy(readJson(file.policy));
|
|
130
459
|
const rules = existsSync(file.rules) ? validateRules(readJson(file.rules)) : null;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
460
|
+
const pathConfig = existsSync(file.fsPaths) ? validatePathsConfig(readJson(file.fsPaths)) : null;
|
|
461
|
+
const tools = loadTools(dir);
|
|
462
|
+
const toolsHash = toolsDigest(tools);
|
|
463
|
+
const toolLock = readToolLock(dir);
|
|
464
|
+
const toolHash =
|
|
465
|
+
typeof proposal.tool_hash === 'string' && proposal.tool_hash !== ''
|
|
466
|
+
? proposal.tool_hash
|
|
467
|
+
: typeof proposal.tool_definition_hash === 'string' && proposal.tool_definition_hash !== ''
|
|
468
|
+
? proposal.tool_definition_hash
|
|
469
|
+
: undefined;
|
|
470
|
+
const mcpDigest =
|
|
471
|
+
typeof proposal.mcp_digest === 'string' && proposal.mcp_digest !== '' ? proposal.mcp_digest : undefined;
|
|
472
|
+
const toolVerdict = toolGate(toolLock, toolsHash, { ...proposal, tool_hash: toolHash });
|
|
473
|
+
const policyHash = policyDigest(policy);
|
|
474
|
+
const rulesHash = rules ? rulesDigest(rules) : undefined;
|
|
475
|
+
const reconciled = reconcileHead(dir, config.head, proposal.task ?? null, key.public);
|
|
476
|
+
const head = reconciled.head;
|
|
477
|
+
const history = reconciled.history;
|
|
478
|
+
const controlVerdict = control
|
|
479
|
+
? {
|
|
480
|
+
decision: 'DENY',
|
|
481
|
+
code: control.mode === 'stop' ? 'TASK_STOPPED' : 'TASK_PAUSED',
|
|
482
|
+
rule: `control.${control.mode}`,
|
|
483
|
+
}
|
|
135
484
|
: null;
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
485
|
+
const pathVerdict = pathConfig ? evaluatePathScope(pathConfig, proposal) : null;
|
|
486
|
+
const verdict = controlVerdict ?? toolVerdict ?? pathVerdict ?? decide({ policy, rules, history, proposal, now });
|
|
487
|
+
const toolPin = toolLock
|
|
488
|
+
? (toolVerdict?.tool_pin ?? (toolHash ? 'verified' : 'unverified'))
|
|
489
|
+
: undefined;
|
|
490
|
+
const meta = { ...(proposal.meta ?? {}) };
|
|
491
|
+
const argsHash = proposal.args_hash ?? meta.args_hash;
|
|
492
|
+
delete meta.args_hash;
|
|
493
|
+
const seq = head.count + 1;
|
|
141
494
|
const entry = buildEntry({
|
|
142
495
|
seq,
|
|
143
|
-
prev:
|
|
496
|
+
prev: head.hash,
|
|
144
497
|
action: proposal.action,
|
|
145
498
|
subject: proposal.subject,
|
|
146
499
|
amount: proposal.amount,
|
|
@@ -150,18 +503,26 @@ export function append(dir, proposal, { now = new Date() } = {}) {
|
|
|
150
503
|
capability: proposal.capability,
|
|
151
504
|
decision: verdict.decision,
|
|
152
505
|
code: verdict.code,
|
|
153
|
-
|
|
506
|
+
rule: verdict.rule,
|
|
507
|
+
taintedBy: verdict.tainted_by,
|
|
508
|
+
argsHash,
|
|
509
|
+
policyDigest: policyHash,
|
|
510
|
+
rulesDigest: rulesHash,
|
|
511
|
+
toolsDigest: toolsHash,
|
|
512
|
+
toolHash,
|
|
513
|
+
toolPin,
|
|
514
|
+
mcpDigest,
|
|
515
|
+
control,
|
|
154
516
|
kid: key.kid,
|
|
155
517
|
at: now.toISOString(),
|
|
156
518
|
});
|
|
157
519
|
entry.sig = signEntry(entry, key.private);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
closeSync(handle);
|
|
163
|
-
}
|
|
520
|
+
writeSnapshot(dir, policyHash, policy);
|
|
521
|
+
if (rules) writeSnapshot(dir, rulesHash, rules);
|
|
522
|
+
writeSnapshot(dir, toolsHash, tools, 'tools');
|
|
523
|
+
writeEntry(file, entry);
|
|
164
524
|
writeJson(file.config, { version: 1, head: { count: seq, hash: entryHash(entry) } });
|
|
525
|
+
publishDecision(entry);
|
|
165
526
|
return entry;
|
|
166
527
|
});
|
|
167
528
|
}
|
package/src/stream.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Read a JSONL ledger one line at a time. The rules match readEntries: a
|
|
2
|
+
// complete malformed line is corruption, a malformed final fragment is a writer
|
|
3
|
+
// caught mid-line and is ignored, and a parseable fragment without a newline is
|
|
4
|
+
// still an entry.
|
|
5
|
+
import { closeSync, createReadStream, openSync, readSync } from 'node:fs';
|
|
6
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
7
|
+
import { parseLine, parseTail } from './jsonl.mjs';
|
|
8
|
+
|
|
9
|
+
// The async reader feeds report and verify; append needs the same byte-exact
|
|
10
|
+
// framing without turning every caller into an async function.
|
|
11
|
+
export function forEachEntrySync(file, onEntry, { startOffset = 0 } = {}) {
|
|
12
|
+
const fd = openSync(file, 'r');
|
|
13
|
+
const decoder = new StringDecoder('utf8');
|
|
14
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
15
|
+
let buffer = '';
|
|
16
|
+
let index = 0;
|
|
17
|
+
let offset = startOffset;
|
|
18
|
+
let readOffset = startOffset;
|
|
19
|
+
try {
|
|
20
|
+
let bytes;
|
|
21
|
+
while ((bytes = readSync(fd, chunk, 0, chunk.length, readOffset)) > 0) {
|
|
22
|
+
readOffset += bytes;
|
|
23
|
+
buffer += decoder.write(chunk.subarray(0, bytes));
|
|
24
|
+
let newline;
|
|
25
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
26
|
+
const line = buffer.slice(0, newline);
|
|
27
|
+
buffer = buffer.slice(newline + 1);
|
|
28
|
+
const start = offset;
|
|
29
|
+
offset += Buffer.byteLength(line, 'utf8') + 1;
|
|
30
|
+
index += 1;
|
|
31
|
+
const keep = onEntry(parseLine(line, index), index, start, offset);
|
|
32
|
+
if (keep === false) return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
buffer += decoder.end();
|
|
36
|
+
if (buffer !== '') {
|
|
37
|
+
const start = offset;
|
|
38
|
+
offset += Buffer.byteLength(buffer, 'utf8');
|
|
39
|
+
const entry = parseTail(buffer, index + 1);
|
|
40
|
+
if (entry !== undefined) onEntry(entry, index + 1, start, offset);
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function forEachEntry(file, onEntry) {
|
|
48
|
+
const stream = createReadStream(file);
|
|
49
|
+
const decoder = new StringDecoder('utf8');
|
|
50
|
+
let buffer = '';
|
|
51
|
+
let index = 0;
|
|
52
|
+
|
|
53
|
+
const handle = async (line, tail) => {
|
|
54
|
+
index += 1;
|
|
55
|
+
const entry = tail ? parseTail(line, index) : parseLine(line, index);
|
|
56
|
+
if (entry === undefined) return;
|
|
57
|
+
await onEntry(entry, index);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for await (const chunk of stream) {
|
|
61
|
+
buffer += decoder.write(chunk);
|
|
62
|
+
let at = buffer.indexOf('\n');
|
|
63
|
+
while (at !== -1) {
|
|
64
|
+
const line = buffer.slice(0, at);
|
|
65
|
+
buffer = buffer.slice(at + 1);
|
|
66
|
+
await handle(line, false);
|
|
67
|
+
at = buffer.indexOf('\n');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
buffer += decoder.end();
|
|
71
|
+
if (buffer !== '') await handle(buffer, true);
|
|
72
|
+
}
|