@verax-ai/body 0.1.1 → 0.1.3
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 +1 -0
- package/dist/agents.d.ts +33 -0
- package/dist/agents.js +59 -0
- package/dist/approve-cli.js +27 -1
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +2 -0
- package/dist/desktop.d.ts +23 -0
- package/dist/desktop.js +82 -30
- package/dist/doctor.js +87 -30
- package/dist/downstream.d.ts +49 -0
- package/dist/downstream.js +274 -0
- package/dist/inputs-read.d.ts +7 -0
- package/dist/inputs-read.js +47 -40
- package/dist/no-bypass-scan.js +7 -0
- package/dist/server.d.ts +183 -0
- package/dist/server.js +269 -44
- package/dist/tools/memory.d.ts +2 -0
- package/dist/tools/memory.js +7 -0
- package/dist/wiring.d.ts +10 -2
- package/dist/wiring.js +31 -4
- package/dist/witness.js +15 -13
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import { approvalsLogFor, approvePending, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
|
|
7
|
+
import { approvalsLogFor, approvePending, createApprovalBudgetGuard, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
|
|
7
8
|
import { createVerifier, readBearer, resourceMetadataUrl, wwwAuthenticate } from "./auth.js";
|
|
8
9
|
import { bumpMetric, bumpUnauthenticated } from "./metrics.js";
|
|
9
10
|
import { isRevokedJti } from "./revoke.js";
|
|
@@ -11,48 +12,102 @@ import { loadOrCreateSigners } from "./keys.js";
|
|
|
11
12
|
import { matchingInputs } from "./inputs-read.js";
|
|
12
13
|
import { readPolicySnapshots } from "./policy-store.js";
|
|
13
14
|
import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
|
|
15
|
+
import { agentsWindow } from "./agents.js";
|
|
14
16
|
import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
|
|
15
17
|
import { createBodyServices, TOOL_NAMES } from "./wiring.js";
|
|
16
|
-
|
|
18
|
+
import { openDownstream, parseDownstreamDocument, } from "./downstream.js";
|
|
19
|
+
// What a brain reads before it calls. Each description says what the tool is
|
|
20
|
+
// for, what it does and does not do, what the gate may answer, and what comes
|
|
21
|
+
// back; each parameter says its format and its bounds. The answers named here
|
|
22
|
+
// are the proxy's: `denied:<reason>:<ref>`, `deferred:approval-required:<ref>`
|
|
23
|
+
// and `allowed:<ref>` (packages/proxy/src/proxy.ts).
|
|
24
|
+
const ID_FORMAT = "1 to 128 characters of letters, digits, '.', '_' or '-', starting with a letter or digit; case-sensitive.";
|
|
25
|
+
const REF_FORMAT = "1 to 64 characters of letters, digits, '.', '_' or '-', starting with a letter or digit.";
|
|
26
|
+
const REF_PARAM = {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Optional reference you choose for this call, " +
|
|
29
|
+
REF_FORMAT +
|
|
30
|
+
" Resend the same call with the same _ref after an operator approved it to receive allowed:<ref>; " +
|
|
31
|
+
"a _ref reused for a different call is refused with denied:ref-reuse.",
|
|
32
|
+
};
|
|
33
|
+
export const TOOL_META = [
|
|
17
34
|
{
|
|
18
35
|
name: "memory.get",
|
|
19
|
-
description: "
|
|
36
|
+
description: "Reads one memory item this tenant stored earlier with memory.put, by its id. " +
|
|
37
|
+
"Use it to recall a fact, a setting or a note before acting on it; nothing is written. " +
|
|
38
|
+
"Like every call it passes the policy gate and leaves a signed decision record; an id that belongs to another tenant is answered with a signed deny. " +
|
|
39
|
+
"Returns the stored item as JSON: {id, body, source, validFromMs, validUntilMs, versionHash}. " +
|
|
40
|
+
"Outside the validity window the body is withheld: {stale: true, id, validUntilMs} after it, {notYetValid: true, id, validFromMs} before it. " +
|
|
41
|
+
'An unknown id answers {error: "not-found", id}.',
|
|
20
42
|
inputSchema: {
|
|
21
43
|
type: "object",
|
|
22
44
|
additionalProperties: false,
|
|
23
|
-
properties: {
|
|
45
|
+
properties: {
|
|
46
|
+
id: { type: "string", description: "The id given to memory.put: " + ID_FORMAT },
|
|
47
|
+
},
|
|
24
48
|
required: ["id"],
|
|
25
49
|
},
|
|
26
50
|
},
|
|
27
51
|
{
|
|
28
52
|
name: "memory.put",
|
|
29
|
-
description: "
|
|
53
|
+
description: "Writes one memory item for this tenant, or replaces the item with the same id, in the body's state directory on this machine. " +
|
|
54
|
+
"Use it to keep a fact for a later memory.get together with where it came from and how long it holds, so a stale fact is not served later. " +
|
|
55
|
+
"The call passes the policy gate and is recorded; the record carries the item's versionHash, a SHA-256 over id, body and validity window. " +
|
|
56
|
+
"Returns {ok: true, id, versionHash}. " +
|
|
57
|
+
'A missing source answers {error: "source-required"}, a missing validUntilMs {error: "validUntilMs-required"}, a malformed id {error: "id-invalid"}.',
|
|
30
58
|
inputSchema: {
|
|
31
59
|
type: "object",
|
|
32
60
|
additionalProperties: false,
|
|
33
61
|
properties: {
|
|
34
|
-
id: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
id: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Identifier to store under and read back with memory.get: " +
|
|
65
|
+
ID_FORMAT +
|
|
66
|
+
" An existing item with this id is replaced.",
|
|
67
|
+
},
|
|
68
|
+
body: {
|
|
69
|
+
description: "The value to keep, as any JSON: object, array, string, number or boolean. Stored as given and returned as given by memory.get.",
|
|
70
|
+
},
|
|
71
|
+
source: {
|
|
72
|
+
type: "object",
|
|
73
|
+
description: 'Where the value came from, as a JSON object of your choosing, for example {"kind": "document", "ref": "invoice-2026-09.pdf"}. Required; stored with the item so a later reader can weigh it.',
|
|
74
|
+
},
|
|
75
|
+
validFromMs: {
|
|
76
|
+
type: "number",
|
|
77
|
+
description: "Optional. Unix time in milliseconds from which the item may be served; before it memory.get answers notYetValid. Omit to serve it at once.",
|
|
78
|
+
},
|
|
79
|
+
validUntilMs: {
|
|
80
|
+
type: "number",
|
|
81
|
+
description: "Required. Unix time in milliseconds after which memory.get answers stale and withholds the body. Pick the moment the fact should no longer be trusted.",
|
|
82
|
+
},
|
|
39
83
|
},
|
|
40
84
|
required: ["id", "body", "source", "validUntilMs"],
|
|
41
85
|
},
|
|
42
86
|
},
|
|
43
87
|
{
|
|
44
88
|
name: "audit.explain",
|
|
45
|
-
description: "
|
|
89
|
+
description: "Reads one decision back from the signed ledger by its ref and explains it. " +
|
|
90
|
+
"Use it to check what the body decided about an earlier call and whether the recorded effect matched, before repeating a call or reporting on it; read-only, and the lookup itself is recorded too. " +
|
|
91
|
+
"Returns JSON with record (the signed decision's claims: tool, verdict, policy hash, timestamps), effect (the reconciled effect row), finding (match, mismatch or missing), witnessClass, guarantee, warnings, trustRoot (which key verified the signatures), and for a held call pair with its defer and resolution records. " +
|
|
92
|
+
"A ref that does not exist, or belongs to another tenant, is answered with the same signed deny, so neither case reveals the other.",
|
|
46
93
|
inputSchema: {
|
|
47
94
|
type: "object",
|
|
48
95
|
additionalProperties: false,
|
|
49
|
-
properties: {
|
|
96
|
+
properties: {
|
|
97
|
+
ref: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "The decision reference: the ref returned by an earlier call, also the tail of a denied:… or deferred:… answer; " +
|
|
100
|
+
REF_FORMAT,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
50
103
|
required: ["ref"],
|
|
51
104
|
},
|
|
52
105
|
},
|
|
53
106
|
{
|
|
54
107
|
name: "message.read",
|
|
55
|
-
description: "
|
|
108
|
+
description: "Reads this tenant's inbox, the messages placed for it in the body's state directory on this machine, and returns them as a JSON array in arrival order, oldest first. " +
|
|
109
|
+
"Use it to see what has arrived before deciding what to answer. " +
|
|
110
|
+
"Takes no arguments; read-only; the call is recorded like every other. An empty or absent inbox answers [].",
|
|
56
111
|
inputSchema: {
|
|
57
112
|
type: "object",
|
|
58
113
|
additionalProperties: false,
|
|
@@ -61,34 +116,63 @@ const TOOL_META = [
|
|
|
61
116
|
},
|
|
62
117
|
{
|
|
63
118
|
name: "message.send",
|
|
64
|
-
description: "
|
|
119
|
+
description: "Queues one message in this tenant's outbox on this machine for the delivery step the operator runs; this call opens no network connection and nothing leaves the body from it. " +
|
|
120
|
+
"Use it to hand off a message, not to deliver one. " +
|
|
121
|
+
"Like every call it passes the policy gate and leaves a signed decision record. " +
|
|
122
|
+
"The gate reads the host after the last '@' in to and allows it only when it is on the policy's egress allow-list; otherwise the call is refused with denied:egress-blocked, or denied:egress-host-missing when no host can be read. " +
|
|
123
|
+
"A policy rule in approve mode holds the call for an operator instead and answers deferred:approval-required:<ref>. " +
|
|
124
|
+
"Returns {queued: true, ref}, where ref is the decision reference for audit.explain.",
|
|
65
125
|
inputSchema: {
|
|
66
126
|
type: "object",
|
|
67
127
|
additionalProperties: false,
|
|
68
128
|
properties: {
|
|
69
|
-
to: {
|
|
70
|
-
|
|
129
|
+
to: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "Recipient address with a host after the last '@', for example ops@example.com. The host, lower-cased, is matched against the policy's egress list.",
|
|
132
|
+
},
|
|
133
|
+
text: { type: "string", description: "The message body as plain text. Stored as given in the outbox row." },
|
|
134
|
+
_ref: REF_PARAM,
|
|
71
135
|
},
|
|
72
136
|
required: ["to", "text"],
|
|
73
137
|
},
|
|
74
138
|
},
|
|
75
139
|
{
|
|
76
140
|
name: "spend",
|
|
77
|
-
description: "
|
|
141
|
+
description: "Asks the body to authorize a payment and records the decision; the body never moves money, so authorized: true is a signed permission for a later payment step, not a transfer. " +
|
|
142
|
+
"Use it before any payment so that amount, currency, payee and reference are checked against the policy: the one currency the policy names, a cap per call, a payee list and a daily limit. " +
|
|
143
|
+
"A call outside those bounds is refused with a signed deny naming the bound: denied:spend-cap, denied:spend-payee, denied:spend-currency or denied:spend-daily. " +
|
|
144
|
+
"A call within them is held for an operator on this machine and answers deferred:approval-required:<ref>; once that ref is approved (verax approve, or the panel), resending the same call with the same _ref answers allowed:<ref>, and the authorization is recorded as {authorized: true, ref, amountMinor, currency, payee, reference}. " +
|
|
145
|
+
"Without a spend rule in the policy every call answers denied:spend-not-wired.",
|
|
78
146
|
inputSchema: {
|
|
79
147
|
type: "object",
|
|
80
148
|
additionalProperties: false,
|
|
81
149
|
properties: {
|
|
82
|
-
amountMinor: {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
150
|
+
amountMinor: {
|
|
151
|
+
type: "integer",
|
|
152
|
+
description: "Amount in the currency's minor unit as a positive integer: cents, kuruş or pence, so 1250 means 12.50. Compared against the policy's cap per call and daily limit.",
|
|
153
|
+
},
|
|
154
|
+
currency: {
|
|
155
|
+
type: "string",
|
|
156
|
+
description: "ISO 4217 code in upper case, for example USD, EUR or TRY. Must equal the currency the policy's spend rule names.",
|
|
157
|
+
},
|
|
158
|
+
payee: {
|
|
159
|
+
type: "string",
|
|
160
|
+
description: "Who is to be paid, spelled exactly as the policy's payee list spells it (a merchant or account name). A payee off the list is refused.",
|
|
161
|
+
},
|
|
162
|
+
reference: {
|
|
163
|
+
type: "string",
|
|
164
|
+
description: "Your own reference for this payment, such as an invoice or order id. Recorded with the authorization and used by verax reconcile to match the card statement.",
|
|
165
|
+
},
|
|
166
|
+
_ref: REF_PARAM,
|
|
86
167
|
},
|
|
87
168
|
required: ["amountMinor", "currency", "payee", "reference"],
|
|
88
169
|
},
|
|
89
170
|
},
|
|
90
171
|
];
|
|
91
172
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
173
|
+
// Unbounded GET /api/ledger stringified ~690 MB at 200k rows and threw Invalid string length (HTTP 500).
|
|
174
|
+
const DEFAULT_LEDGER_LIMIT = 1000;
|
|
175
|
+
const MAX_LEDGER_LIMIT = 5000;
|
|
92
176
|
const responseSlot = new AsyncLocalStorage();
|
|
93
177
|
function contentLengthOverLimit(req) {
|
|
94
178
|
const raw = req.headers["content-length"];
|
|
@@ -139,17 +223,94 @@ function send(res, status, body, headers) {
|
|
|
139
223
|
});
|
|
140
224
|
res.end(text);
|
|
141
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Republishes a child's own `tools/list` entry under its prefixed name. The
|
|
228
|
+
* description is the child's; the sentence added here says what changes by
|
|
229
|
+
* going through the body, because the answers a caller gets back
|
|
230
|
+
* (`denied:…`, `deferred:…`) are the gate's, not the child's.
|
|
231
|
+
*/
|
|
232
|
+
function downstreamMeta(tool) {
|
|
233
|
+
const own = typeof tool.description === "string" && tool.description !== "" ? `${tool.description} ` : "";
|
|
234
|
+
return {
|
|
235
|
+
name: tool.name,
|
|
236
|
+
description: own +
|
|
237
|
+
"Forwarded by this body to the downstream server that published it: the call passes the same policy gate " +
|
|
238
|
+
"and leaves the same signed decision under this name, so it may be answered with denied:<reason>:<ref> or " +
|
|
239
|
+
"deferred:approval-required:<ref> before the downstream server ever sees it.",
|
|
240
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
async function closeAll(sessions) {
|
|
244
|
+
for (const session of sessions) {
|
|
245
|
+
await session.close().catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What an operator may see about the attached children: the prefix they call
|
|
250
|
+
* by, how the body reaches them, and the names it will accept.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately not the address. A child's URL can carry its token in the path
|
|
253
|
+
* — the live Conarium's does — and a command line names a path on this host.
|
|
254
|
+
* Neither is needed to answer "what is standing behind this gate", so neither
|
|
255
|
+
* is published. The `tests/downstream-visible` guard asserts their absence.
|
|
256
|
+
*/
|
|
257
|
+
function downstreamPublic(sessions, specs) {
|
|
258
|
+
return sessions.map((session) => {
|
|
259
|
+
const spec = specs.find((s) => s.prefix === session.prefix);
|
|
260
|
+
return {
|
|
261
|
+
prefix: session.prefix,
|
|
262
|
+
transport: spec?.url !== undefined ? "http" : "stdio",
|
|
263
|
+
tools: session.tools.map((t) => t.name),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Opens every child the document names. One child that refuses to attach
|
|
269
|
+
* closes the ones already open and throws: a half-attached body would serve a
|
|
270
|
+
* tool list its operator never wrote.
|
|
271
|
+
*/
|
|
272
|
+
async function attachDownstream(file) {
|
|
273
|
+
if (file === null || file.trim() === "")
|
|
274
|
+
return { sessions: [], specs: [] };
|
|
275
|
+
const specs = parseDownstreamDocument(readFileSync(file, "utf8"));
|
|
276
|
+
const sessions = [];
|
|
277
|
+
for (const spec of specs) {
|
|
278
|
+
try {
|
|
279
|
+
sessions.push(await openDownstream(spec));
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
await closeAll(sessions);
|
|
283
|
+
const detail = err instanceof Error ? err.message : "attach-failed";
|
|
284
|
+
throw new Error(`downstream-attach-failed:${spec.prefix}:${detail}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { sessions, specs };
|
|
288
|
+
}
|
|
142
289
|
export async function listen(config) {
|
|
143
290
|
const signers = loadOrCreateSigners(config.stateDir);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
291
|
+
// The children are attached before the door opens. A named document the body
|
|
292
|
+
// cannot honour stops the start: a body that serves six tools while its
|
|
293
|
+
// operator wrote seven is answering for a gate it does not have.
|
|
294
|
+
const { sessions, specs: downstreamSpecs } = await attachDownstream(config.downstreamFile ?? null);
|
|
295
|
+
const extraTools = sessions.flatMap((session) => session.tools);
|
|
296
|
+
let services;
|
|
297
|
+
try {
|
|
298
|
+
services = createBodyServices({
|
|
299
|
+
stateDir: config.stateDir,
|
|
300
|
+
policyFile: config.policyFile,
|
|
301
|
+
recordSigner: signers.recordSigner,
|
|
302
|
+
effectSigner: signers.effectSigner,
|
|
303
|
+
...(extraTools.length > 0 ? { extraTools } : {}),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
await closeAll(sessions);
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
|
|
150
311
|
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
|
|
151
312
|
const attachHandlers = (mcp) => {
|
|
152
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools:
|
|
313
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
|
|
153
314
|
mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
154
315
|
const auth = extra?.authInfo;
|
|
155
316
|
const scopes = new Set(auth?.scopes ?? []);
|
|
@@ -209,18 +370,33 @@ export async function listen(config) {
|
|
|
209
370
|
send(res, 200, { ok: true });
|
|
210
371
|
return;
|
|
211
372
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
373
|
+
// The ledger counts its own lines as it writes them; asking it is free.
|
|
374
|
+
// Reading both files back to count them was one second per call on a
|
|
375
|
+
// 100k-decision ledger, five times a minute for as long as a panel was open.
|
|
376
|
+
// When the active piece cannot be read back, the fallback below counts
|
|
377
|
+
// the merged files; it cannot tell which rows sit in the active piece,
|
|
378
|
+
// so the piece fields stay out of that degraded answer.
|
|
379
|
+
let counted = services.ledger.counts();
|
|
380
|
+
if (!counted) {
|
|
381
|
+
const decisions = await services.ledger.decisions();
|
|
382
|
+
const effects = await services.ledger.effects();
|
|
383
|
+
const last = decisions[decisions.length - 1];
|
|
384
|
+
counted = {
|
|
385
|
+
decisions: decisions.length,
|
|
386
|
+
effects: effects.length,
|
|
387
|
+
lastDecisionMs: last ? last.claims.timestampMs : null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
215
390
|
send(res, 200, {
|
|
216
391
|
ok: true,
|
|
217
|
-
|
|
218
|
-
effects: effects.length,
|
|
219
|
-
lastDecisionMs: last ? last.claims.timestampMs : null,
|
|
392
|
+
...counted,
|
|
220
393
|
lock: services.ledger.lockStatus(),
|
|
221
394
|
heartbeat: readHeartbeat(config.stateDir),
|
|
222
395
|
witness: readWitnessPulse(config.stateDir),
|
|
223
396
|
inventory: inventoryHealth(readInventoryFile(config.inventoryFile)),
|
|
397
|
+
// Always an array, empty when nothing is attached: a missing field
|
|
398
|
+
// would read as "this body is too old to tell you".
|
|
399
|
+
downstream: downstreamPublic(sessions, downstreamSpecs),
|
|
224
400
|
});
|
|
225
401
|
return;
|
|
226
402
|
}
|
|
@@ -244,7 +420,8 @@ export async function listen(config) {
|
|
|
244
420
|
const apiInventory = url.pathname === "/api/inventory";
|
|
245
421
|
const contest = req.method === "POST" && url.pathname.startsWith("/api/contest/");
|
|
246
422
|
const apiApprove = req.method === "POST" && url.pathname === "/api/approve";
|
|
247
|
-
|
|
423
|
+
const apiAgents = req.method === "GET" && url.pathname === "/api/agents";
|
|
424
|
+
if (url.pathname !== "/mcp" && !apiLedger && !apiInventory && !contest && !apiApprove && !apiAgents) {
|
|
248
425
|
send(res, 404, { error: "not-found" });
|
|
249
426
|
return;
|
|
250
427
|
}
|
|
@@ -303,9 +480,8 @@ export async function listen(config) {
|
|
|
303
480
|
send(res, 409, { error: "stale", requestHash: waiting.requestHash });
|
|
304
481
|
return;
|
|
305
482
|
}
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
if (!defer) {
|
|
483
|
+
const defer = services.ledger.lookupByRef(ref);
|
|
484
|
+
if (!defer || defer.decision !== "defer") {
|
|
309
485
|
send(res, 404, { error: "unknown-ref" });
|
|
310
486
|
return;
|
|
311
487
|
}
|
|
@@ -313,6 +489,7 @@ export async function listen(config) {
|
|
|
313
489
|
// which says nothing about the person holding the phone; the session's
|
|
314
490
|
// own subject does.
|
|
315
491
|
const approver = verified.principal.brain;
|
|
492
|
+
const approvals = approvalsLogFor(services.ledger);
|
|
316
493
|
const outcome = await approvePending({
|
|
317
494
|
ledger: services.ledger,
|
|
318
495
|
recordSigner: loadOrCreateSigners(config.stateDir).recordSigner,
|
|
@@ -321,18 +498,26 @@ export async function listen(config) {
|
|
|
321
498
|
ref,
|
|
322
499
|
approverId: approver,
|
|
323
500
|
via: "http",
|
|
324
|
-
policyHash: defer.
|
|
325
|
-
approvals
|
|
501
|
+
policyHash: defer.policyHash,
|
|
502
|
+
approvals,
|
|
503
|
+
budgetGuard: createApprovalBudgetGuard({
|
|
504
|
+
policy: services.policy,
|
|
505
|
+
approvals,
|
|
506
|
+
now: () => Date.now(),
|
|
507
|
+
}),
|
|
326
508
|
});
|
|
327
509
|
if (!outcome.ok) {
|
|
328
510
|
const code = outcome.reason === "unknown-ref" || outcome.reason === "snapshot-missing" ? 404 : 409;
|
|
329
|
-
send(res, code, {
|
|
511
|
+
send(res, code, {
|
|
512
|
+
error: outcome.reason,
|
|
513
|
+
...(outcome.allowRef ? { allowRef: outcome.allowRef } : {}),
|
|
514
|
+
});
|
|
330
515
|
return;
|
|
331
516
|
}
|
|
332
517
|
send(res, 200, { allowRef: outcome.allowRef, approver });
|
|
333
518
|
return;
|
|
334
519
|
}
|
|
335
|
-
if (apiLedger || apiInventory || contest) {
|
|
520
|
+
if (apiLedger || apiInventory || contest || apiAgents) {
|
|
336
521
|
// The audit doors hand out the whole ledger: every tenant's decisions, the
|
|
337
522
|
// inputs documents that name their principals, and the approval snapshots
|
|
338
523
|
// that carry spend arguments. `verax:read` is a brain scope, so it cannot be
|
|
@@ -349,11 +534,47 @@ export async function listen(config) {
|
|
|
349
534
|
send(res, 200, readInventoryFile(config.inventoryFile));
|
|
350
535
|
return;
|
|
351
536
|
}
|
|
537
|
+
if (apiAgents) {
|
|
538
|
+
// The last day unless the caller names a window; the roster's
|
|
539
|
+
// agents are on the list whether or not they acted in it.
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
const fromRaw = url.searchParams.get("from");
|
|
542
|
+
const toRaw = url.searchParams.get("to");
|
|
543
|
+
const from = fromRaw === null ? now - 86_400_000 : Number(fromRaw);
|
|
544
|
+
const to = toRaw === null ? now : Number(toRaw);
|
|
545
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
546
|
+
send(res, 400, { error: "bad-window" });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
send(res, 200, await agentsWindow({
|
|
550
|
+
ledger: services.ledger,
|
|
551
|
+
stateDir: config.stateDir,
|
|
552
|
+
inventoryFile: config.inventoryFile,
|
|
553
|
+
fromMs: from,
|
|
554
|
+
toMs: to,
|
|
555
|
+
}));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
352
558
|
if (apiLedger) {
|
|
353
559
|
const from = Number(url.searchParams.get("from") ?? "0");
|
|
354
560
|
const to = Number(url.searchParams.get("to") ?? String(Number.MAX_SAFE_INTEGER));
|
|
355
|
-
const
|
|
356
|
-
const
|
|
561
|
+
const limitRaw = url.searchParams.get("limit");
|
|
562
|
+
const parsedLimit = limitRaw === null ? undefined : Number(limitRaw);
|
|
563
|
+
if (!Number.isFinite(from) ||
|
|
564
|
+
!Number.isFinite(to) ||
|
|
565
|
+
(parsedLimit !== undefined && (!Number.isFinite(parsedLimit) || parsedLimit < 0))) {
|
|
566
|
+
send(res, 400, { error: "bad-window" });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const limit = parsedLimit === undefined ? DEFAULT_LEDGER_LIMIT : Math.min(MAX_LEDGER_LIMIT, Math.floor(parsedLimit));
|
|
570
|
+
// The window is read from the end of the files, so a day costs a
|
|
571
|
+
// day whatever the ledger's age. With a limit, the newest rows of
|
|
572
|
+
// the window come back and `more` says the rest is there to ask for.
|
|
573
|
+
const { rows: decisions, more, piecesTouched } = await services.ledger.decisionsWindow(from, to, limit);
|
|
574
|
+
// Effects belong to the decisions returned. When the limit cut the
|
|
575
|
+
// window, the oldest decision returned is where their window starts.
|
|
576
|
+
const effectsFrom = more && decisions.length > 0 ? decisions[0].claims.timestampMs : from;
|
|
577
|
+
const effects = await services.ledger.effectsWindow(effectsFrom, to);
|
|
357
578
|
const hashes = [...new Set(decisions.map((d) => d.claims.policyHash))];
|
|
358
579
|
send(res, 200, {
|
|
359
580
|
decisions,
|
|
@@ -362,6 +583,9 @@ export async function listen(config) {
|
|
|
362
583
|
policies: readPolicySnapshots(config.stateDir, hashes),
|
|
363
584
|
inputs: await matchingInputs(config.stateDir, decisions),
|
|
364
585
|
approvals: loadApprovalsFromDir(config.stateDir),
|
|
586
|
+
more,
|
|
587
|
+
piecesTouched,
|
|
588
|
+
limit,
|
|
365
589
|
});
|
|
366
590
|
return;
|
|
367
591
|
}
|
|
@@ -478,6 +702,7 @@ export async function listen(config) {
|
|
|
478
702
|
});
|
|
479
703
|
server.on("close", () => {
|
|
480
704
|
services.ledger.close();
|
|
705
|
+
void closeAll(sessions);
|
|
481
706
|
});
|
|
482
707
|
return server;
|
|
483
708
|
}
|
package/dist/tools/memory.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
2
|
/** True when `id` exists under a different tenant. Does not read legacy `memory/`. */
|
|
3
3
|
export declare function memoryBelongsToOtherTenant(stateDir: string, id: string, selfKey: string): boolean;
|
|
4
|
+
/** True when this tenant already has `id` under its own memory path. */
|
|
5
|
+
export declare function memoryExistsForTenant(stateDir: string, id: string, principal: Principal): boolean;
|
|
4
6
|
export declare function readMemoryMeta(stateDir: string, id: string, principal?: Principal): Promise<{
|
|
5
7
|
versionHash: string;
|
|
6
8
|
validFromMs: number;
|
package/dist/tools/memory.js
CHANGED
|
@@ -51,6 +51,13 @@ export function memoryBelongsToOtherTenant(stateDir, id, selfKey) {
|
|
|
51
51
|
}
|
|
52
52
|
return false;
|
|
53
53
|
}
|
|
54
|
+
/** True when this tenant already has `id` under its own memory path. */
|
|
55
|
+
export function memoryExistsForTenant(stateDir, id, principal) {
|
|
56
|
+
const path = resolveMemoryPath(stateDir, id, principal);
|
|
57
|
+
if (path === null)
|
|
58
|
+
return false;
|
|
59
|
+
return existsSync(path);
|
|
60
|
+
}
|
|
54
61
|
export async function readMemoryMeta(stateDir, id, principal) {
|
|
55
62
|
if (!principal)
|
|
56
63
|
return null;
|
package/dist/wiring.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { createProxy, FileLedger, type EffectSigner, type ExplainOpts, type Principal, type RecordSigner, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
1
|
+
import { createProxy, FileLedger, type EffectSigner, type ExplainOpts, type Policy, type Principal, type RecordSigner, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
2
|
export type ToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
|
|
3
3
|
export declare const TOOL_NAMES: readonly ["memory.get", "memory.put", "audit.explain", "message.read", "message.send", "spend"];
|
|
4
|
+
export type ExtraTool = {
|
|
5
|
+
name: string;
|
|
6
|
+
fn: ToolFn;
|
|
7
|
+
};
|
|
4
8
|
export type BodyServices = {
|
|
5
9
|
proxy: ReturnType<typeof createProxy>;
|
|
6
10
|
ledger: FileLedger;
|
|
11
|
+
policy: Policy;
|
|
7
12
|
policyHash: string;
|
|
8
13
|
policyDocument: unknown;
|
|
9
14
|
listTools: () => readonly string[];
|
|
@@ -11,7 +16,9 @@ export type BodyServices = {
|
|
|
11
16
|
};
|
|
12
17
|
/**
|
|
13
18
|
* Tool functions live in a Map that is not exported. The only way to
|
|
14
|
-
* reach them at runtime is `proxy.call` -> `inner`.
|
|
19
|
+
* reach them at runtime is `proxy.call` -> `inner`. Extra tools (a
|
|
20
|
+
* downstream prefix) enter that Map at construction; they still pass
|
|
21
|
+
* the gate.
|
|
15
22
|
*/
|
|
16
23
|
export declare function createBodyServices(opts: {
|
|
17
24
|
stateDir: string;
|
|
@@ -20,4 +27,5 @@ export declare function createBodyServices(opts: {
|
|
|
20
27
|
effectSigner: EffectSigner;
|
|
21
28
|
now?: () => number;
|
|
22
29
|
nonce?: () => string;
|
|
30
|
+
extraTools?: readonly ExtraTool[];
|
|
23
31
|
}): BodyServices;
|
package/dist/wiring.js
CHANGED
|
@@ -2,11 +2,11 @@ import { createProxy, FileLedger, loadPolicy, tenantKey, } from "@verax-ai/proxy
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { persistPolicySnapshot } from "./policy-store.js";
|
|
4
4
|
import { inputsPrincipal } from "./inputs-read.js";
|
|
5
|
-
import { memoryBelongsToOtherTenant, memoryGet, memoryPut, readMemoryMeta } from "./tools/memory.js";
|
|
5
|
+
import { memoryBelongsToOtherTenant, memoryExistsForTenant, memoryGet, memoryPut, readMemoryMeta, } from "./tools/memory.js";
|
|
6
6
|
import { auditExplain } from "./tools/audit.js";
|
|
7
7
|
import { messageRead, messageSend } from "./tools/message.js";
|
|
8
8
|
import { spendAuthorize } from "./tools/spend.js";
|
|
9
|
-
import { requestWitnessSign } from "./witness.js";
|
|
9
|
+
import { requestWitnessCheckpoint, requestWitnessSign } from "./witness.js";
|
|
10
10
|
export const TOOL_NAMES = [
|
|
11
11
|
"memory.get",
|
|
12
12
|
"memory.put",
|
|
@@ -17,11 +17,32 @@ export const TOOL_NAMES = [
|
|
|
17
17
|
];
|
|
18
18
|
/**
|
|
19
19
|
* Tool functions live in a Map that is not exported. The only way to
|
|
20
|
-
* reach them at runtime is `proxy.call` -> `inner`.
|
|
20
|
+
* reach them at runtime is `proxy.call` -> `inner`. Extra tools (a
|
|
21
|
+
* downstream prefix) enter that Map at construction; they still pass
|
|
22
|
+
* the gate.
|
|
21
23
|
*/
|
|
22
24
|
export function createBodyServices(opts) {
|
|
25
|
+
const extraNames = [];
|
|
26
|
+
const reserved = new Set(TOOL_NAMES);
|
|
27
|
+
for (const tool of opts.extraTools ?? []) {
|
|
28
|
+
if (reserved.has(tool.name) || extraNames.includes(tool.name)) {
|
|
29
|
+
throw new Error(`downstream-name-collision:${tool.name}`);
|
|
30
|
+
}
|
|
31
|
+
// Same shape as extraToolNameOk in downstream.ts (prefix.childName).
|
|
32
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(tool.name)) {
|
|
33
|
+
throw new Error(`downstream-name-invalid:${tool.name}`);
|
|
34
|
+
}
|
|
35
|
+
extraNames.push(tool.name);
|
|
36
|
+
}
|
|
23
37
|
const ledger = new FileLedger(opts.stateDir);
|
|
24
38
|
ledger.remoteWitness = (row, resultHash) => requestWitnessSign(opts.stateDir, row, resultHash);
|
|
39
|
+
ledger.onPieceClose = async (window) => {
|
|
40
|
+
await requestWitnessCheckpoint(opts.stateDir, {
|
|
41
|
+
epoch: 0,
|
|
42
|
+
startMs: window.startMs,
|
|
43
|
+
endMs: window.endMs,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
25
46
|
const policyText = readFileSync(opts.policyFile, "utf8");
|
|
26
47
|
const policyDocument = JSON.parse(policyText);
|
|
27
48
|
const policy = loadPolicy(policyText);
|
|
@@ -49,6 +70,9 @@ export function createBodyServices(opts) {
|
|
|
49
70
|
registry.set("message.read", (call, principal) => messageRead(call, opts.stateDir, principal));
|
|
50
71
|
registry.set("message.send", (call, principal, ref) => messageSend(call, opts.stateDir, ref ?? "", principal));
|
|
51
72
|
registry.set("spend", (call, _principal, ref) => spendAuthorize(call, ref ?? ""));
|
|
73
|
+
for (const tool of opts.extraTools ?? []) {
|
|
74
|
+
registry.set(tool.name, tool.fn);
|
|
75
|
+
}
|
|
52
76
|
const inner = async (call, principal, ref) => {
|
|
53
77
|
const fn = registry.get(call.name);
|
|
54
78
|
if (!fn) {
|
|
@@ -73,6 +97,8 @@ export function createBodyServices(opts) {
|
|
|
73
97
|
const id = call.arguments.id;
|
|
74
98
|
if (typeof id !== "string")
|
|
75
99
|
return false;
|
|
100
|
+
if (memoryExistsForTenant(opts.stateDir, id, principal))
|
|
101
|
+
return false;
|
|
76
102
|
return memoryBelongsToOtherTenant(opts.stateDir, id, tenantKey(principal));
|
|
77
103
|
}
|
|
78
104
|
if (call.name === "audit.explain") {
|
|
@@ -93,9 +119,10 @@ export function createBodyServices(opts) {
|
|
|
93
119
|
return {
|
|
94
120
|
proxy,
|
|
95
121
|
ledger,
|
|
122
|
+
policy,
|
|
96
123
|
policyHash: policy.hash,
|
|
97
124
|
policyDocument,
|
|
98
|
-
listTools: () => TOOL_NAMES,
|
|
125
|
+
listTools: () => (extraNames.length === 0 ? TOOL_NAMES : [...TOOL_NAMES, ...extraNames]),
|
|
99
126
|
explainOpts,
|
|
100
127
|
};
|
|
101
128
|
}
|
package/dist/witness.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
6
6
|
import { buildCheckpointClaims, checkpointHash, signCheckpoint, totalsFromDecisionRecords, } from "@cedulon/checkpoint";
|
|
7
7
|
import { decisionRecordHash } from "@cedulon/core";
|
|
8
|
-
import { checkpointsPath, signEffectAttestation } from "@verax-ai/proxy";
|
|
8
|
+
import { checkpointsPath, listPieceFiles, signEffectAttestation } from "@verax-ai/proxy";
|
|
9
9
|
import { pidAlive } from "./unlock.js";
|
|
10
10
|
const WITNESS_CLASS = "same-org";
|
|
11
11
|
function listenPath(stateDir) {
|
|
@@ -104,21 +104,23 @@ function send(res, status, body) {
|
|
|
104
104
|
res.end(text);
|
|
105
105
|
}
|
|
106
106
|
function loadDecisions(stateDir) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const piece of listPieceFiles(stateDir)) {
|
|
109
|
+
try {
|
|
110
|
+
const text = readFileSync(piece.decisions, "utf8");
|
|
111
|
+
for (const line of text.split("\n")) {
|
|
112
|
+
if (line === "")
|
|
113
|
+
continue;
|
|
114
|
+
out.push(JSON.parse(line));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT")
|
|
112
119
|
continue;
|
|
113
|
-
|
|
120
|
+
throw err;
|
|
114
121
|
}
|
|
115
|
-
return out;
|
|
116
|
-
}
|
|
117
|
-
catch (err) {
|
|
118
|
-
if (err.code === "ENOENT")
|
|
119
|
-
return [];
|
|
120
|
-
throw err;
|
|
121
122
|
}
|
|
123
|
+
return out;
|
|
122
124
|
}
|
|
123
125
|
function lastCheckpointHash(stateDir) {
|
|
124
126
|
try {
|