@verax-ai/body 0.3.0 → 0.4.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/README.md +7 -2
- package/dist/approve-cli.d.ts +22 -1
- package/dist/approve-cli.js +209 -22
- package/dist/auth.d.ts +9 -1
- package/dist/auth.js +12 -2
- package/dist/body-log.d.ts +14 -0
- package/dist/body-log.js +61 -0
- package/dist/cli.d.ts +23 -1
- package/dist/cli.js +227 -94
- package/dist/config.d.ts +26 -0
- package/dist/config.js +47 -0
- package/dist/demo.d.ts +2 -0
- package/dist/demo.js +8 -2
- package/dist/desktop-parent.d.ts +19 -0
- package/dist/desktop-parent.js +44 -0
- package/dist/desktop.d.ts +140 -11
- package/dist/desktop.js +931 -103
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +94 -11
- package/dist/health-extras.d.ts +4 -0
- package/dist/health-extras.js +14 -0
- package/dist/init-local.d.ts +25 -1
- package/dist/init-local.js +151 -54
- package/dist/install.d.ts +745 -0
- package/dist/install.js +5738 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +43 -20
- package/dist/main.js +11 -1
- package/dist/no-bypass-scan.js +3 -2
- package/dist/reconcile-cli.js +3 -1
- package/dist/server.d.ts +14 -1
- package/dist/server.js +125 -3
- package/dist/tools/audit.d.ts +5 -2
- package/dist/tools/audit.js +12 -2
- package/dist/tools/memory.js +41 -6
- package/dist/tools/message.js +38 -9
- package/dist/unlock.d.ts +5 -2
- package/dist/unlock.js +6 -1
- package/dist/verify-cli.js +135 -15
- package/dist/wiring.js +1 -1
- package/dist/witness.js +154 -24
- package/package.json +7 -3
package/dist/keys.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export declare class KeysPartialError extends Error {
|
|
|
6
6
|
readonly code = 78;
|
|
7
7
|
constructor();
|
|
8
8
|
}
|
|
9
|
+
export declare class KeyAlgorithmError extends Error {
|
|
10
|
+
readonly code = 78;
|
|
11
|
+
constructor(which: string);
|
|
12
|
+
}
|
|
13
|
+
/** A public key this process will verify with. Anything but Ed25519 stops start. */
|
|
14
|
+
export declare function assertEd25519PublicKey(pem: string, which: string): void;
|
|
9
15
|
export declare function loadOrCreateSigners(stateDir: string): {
|
|
10
16
|
recordSigner: KeyPair;
|
|
11
17
|
effectSigner: KeyPair;
|
package/dist/keys.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generateKeyPairSync } from "node:crypto";
|
|
1
|
+
import { createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { EX_CONFIG } from "./config.js";
|
|
@@ -9,6 +9,25 @@ export class KeysPartialError extends Error {
|
|
|
9
9
|
this.name = "KeysPartialError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
export class KeyAlgorithmError extends Error {
|
|
13
|
+
code = EX_CONFIG;
|
|
14
|
+
constructor(which) {
|
|
15
|
+
super(`${which} public key is not ed25519`);
|
|
16
|
+
this.name = "KeyAlgorithmError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** A public key this process will verify with. Anything but Ed25519 stops start. */
|
|
20
|
+
export function assertEd25519PublicKey(pem, which) {
|
|
21
|
+
let type;
|
|
22
|
+
try {
|
|
23
|
+
type = createPublicKey(pem).asymmetricKeyType;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new KeyAlgorithmError(which);
|
|
27
|
+
}
|
|
28
|
+
if (type !== "ed25519")
|
|
29
|
+
throw new KeyAlgorithmError(which);
|
|
30
|
+
}
|
|
12
31
|
function pair() {
|
|
13
32
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
14
33
|
return {
|
|
@@ -33,23 +52,27 @@ export function loadOrCreateSigners(stateDir) {
|
|
|
33
52
|
if (present !== 0 && present !== 4) {
|
|
34
53
|
throw new KeysPartialError();
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const loaded = present === 0
|
|
56
|
+
? (() => {
|
|
57
|
+
const record = pair();
|
|
58
|
+
const effect = pair();
|
|
59
|
+
writePemAtomic(recPriv, record.privateKeyPem);
|
|
60
|
+
writePemAtomic(recPub, record.publicKeyPem);
|
|
61
|
+
writePemAtomic(effPriv, effect.privateKeyPem);
|
|
62
|
+
writePemAtomic(effPub, effect.publicKeyPem);
|
|
63
|
+
return { recordSigner: record, effectSigner: effect };
|
|
64
|
+
})()
|
|
65
|
+
: {
|
|
66
|
+
recordSigner: {
|
|
67
|
+
privateKeyPem: readFileSync(recPriv, "utf8"),
|
|
68
|
+
publicKeyPem: readFileSync(recPub, "utf8"),
|
|
69
|
+
},
|
|
70
|
+
effectSigner: {
|
|
71
|
+
privateKeyPem: readFileSync(effPriv, "utf8"),
|
|
72
|
+
publicKeyPem: readFileSync(effPub, "utf8"),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
assertEd25519PublicKey(loaded.recordSigner.publicKeyPem, "record");
|
|
76
|
+
assertEd25519PublicKey(loaded.effectSigner.publicKeyPem, "effect");
|
|
77
|
+
return loaded;
|
|
55
78
|
}
|
package/dist/main.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { JwksFileError } from "./auth.js";
|
|
2
2
|
import { EX_CONFIG, loadConfig, overlayInventoryArg } from "./config.js";
|
|
3
|
-
import {
|
|
3
|
+
import { watchDesktopParent } from "./desktop-parent.js";
|
|
4
|
+
import { KeyAlgorithmError, KeysPartialError } from "./keys.js";
|
|
4
5
|
import { listen } from "./server.js";
|
|
5
6
|
export async function main(env = process.env, argv = process.argv) {
|
|
7
|
+
watchDesktopParent({ env });
|
|
6
8
|
const overlaid = overlayInventoryArg(env, argv);
|
|
7
9
|
if ("error" in overlaid) {
|
|
8
10
|
process.stderr.write(`${overlaid.error}\n`);
|
|
@@ -15,6 +17,10 @@ export async function main(env = process.env, argv = process.argv) {
|
|
|
15
17
|
}
|
|
16
18
|
try {
|
|
17
19
|
const server = await listen(loaded.value);
|
|
20
|
+
const addr = server.address();
|
|
21
|
+
if (addr && typeof addr === "object") {
|
|
22
|
+
process.stderr.write(`listening ${addr.address}:${addr.port}\n`);
|
|
23
|
+
}
|
|
18
24
|
const shutdown = () => {
|
|
19
25
|
server.close(() => process.exit(0));
|
|
20
26
|
};
|
|
@@ -26,6 +32,10 @@ export async function main(env = process.env, argv = process.argv) {
|
|
|
26
32
|
process.stderr.write("keys-partial\n");
|
|
27
33
|
process.exit(err.code);
|
|
28
34
|
}
|
|
35
|
+
if (err instanceof KeyAlgorithmError) {
|
|
36
|
+
process.stderr.write(`${err.message}\n`);
|
|
37
|
+
process.exit(err.code);
|
|
38
|
+
}
|
|
29
39
|
if (err instanceof JwksFileError) {
|
|
30
40
|
process.stderr.write(`${err.message}\n`);
|
|
31
41
|
process.exit(err.code);
|
package/dist/no-bypass-scan.js
CHANGED
|
@@ -52,8 +52,9 @@ export function scanNoBypass(base = pkgRoot, extra = []) {
|
|
|
52
52
|
if (rel === "src/no-bypass-scan.ts")
|
|
53
53
|
continue;
|
|
54
54
|
const wiring = rel === "src/wiring.ts";
|
|
55
|
-
// Desktop supervises issuer/body/panel.
|
|
56
|
-
|
|
55
|
+
// Desktop supervises issuer/body/panel. Install copies the body under another
|
|
56
|
+
// account. Neither file is a tool-call path.
|
|
57
|
+
const desktop = rel === "src/desktop.ts" || rel === "src/install.ts";
|
|
57
58
|
// The downstream spike attaches one stdio MCP server. Not a tool-call path.
|
|
58
59
|
const downstream = rel === "src/downstream.ts";
|
|
59
60
|
const originalLines = text.split(/\r?\n/);
|
package/dist/reconcile-cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { loadApprovalsFromDir, loadEffectsFromDir, loadPolicy, parseCardCsv, parseChannelJsonl, reconcile } from "@verax-ai/proxy";
|
|
3
|
+
import { loadApprovalsFromDir, loadDecisionRefsFromDir, loadEffectsFromDir, loadPolicy, parseCardCsv, parseChannelJsonl, reconcile, } from "@verax-ai/proxy";
|
|
4
4
|
const SNAPSHOT_NAME = /^[0-9a-f]{64}\.json$/;
|
|
5
5
|
function descriptorsFromDoc(doc) {
|
|
6
6
|
const out = {};
|
|
@@ -229,11 +229,13 @@ export function runReconcile(argv, writeErr = (s) => process.stderr.write(s)) {
|
|
|
229
229
|
: undefined;
|
|
230
230
|
const channel = card ?? parseChannelJsonl(raw);
|
|
231
231
|
const effects = loadEffectsFromDir(parsed.stateDir);
|
|
232
|
+
const decisionRefs = loadDecisionRefsFromDir(parsed.stateDir);
|
|
232
233
|
const loaded = parsed.channel === "card" ? loadDescriptorsFromDir(parsed.stateDir) : undefined;
|
|
233
234
|
const descriptorsByPayee = loaded?.descriptorsByPayee;
|
|
234
235
|
const report = reconcile(channel, effects, {
|
|
235
236
|
toleranceMs: parsed.toleranceMs,
|
|
236
237
|
window: parsed.window,
|
|
238
|
+
decisionRefs,
|
|
237
239
|
approvals: parsed.channel === "card" ? loadApprovalsFromDir(parsed.stateDir) : undefined,
|
|
238
240
|
skipped: card?.skipped,
|
|
239
241
|
...(descriptorsByPayee && Object.keys(descriptorsByPayee).length > 0
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Server } from "node:http";
|
|
2
|
-
import type
|
|
2
|
+
import { type BodyConfig } from "./config.ts";
|
|
3
3
|
import { TOOL_NAMES } from "./wiring.ts";
|
|
4
4
|
export declare const TOOL_META: ({
|
|
5
5
|
name: string;
|
|
@@ -184,5 +184,18 @@ export declare const TOOL_META: ({
|
|
|
184
184
|
required: string[];
|
|
185
185
|
};
|
|
186
186
|
})[];
|
|
187
|
+
/** `malformed` falls through so `Host: [` stays the existing bad-request. */
|
|
188
|
+
export declare function loopbackHostDecision(hostHeader: string | undefined, bindPort: number): "allow" | "deny" | "malformed";
|
|
189
|
+
/**
|
|
190
|
+
* Second look at a bearer that already verified. The first look runs before
|
|
191
|
+
* the body is read; this one runs after the body and before dispatch or
|
|
192
|
+
* approval. `exp` is seconds since the epoch. A passed `exp` is dead even
|
|
193
|
+
* inside the verifier's clock tolerance: that tolerance applied at the door,
|
|
194
|
+
* and the body has since arrived. A missing `exp` is dead too.
|
|
195
|
+
*/
|
|
196
|
+
export declare function bearerStillLive(payload: {
|
|
197
|
+
jti?: unknown;
|
|
198
|
+
exp?: unknown;
|
|
199
|
+
}, stateDir: string): boolean;
|
|
187
200
|
export declare function listen(config: BodyConfig): Promise<Server>;
|
|
188
201
|
export { TOOL_NAMES };
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { isLoopbackHost } from "./config.js";
|
|
7
8
|
import { approvalsLogFor, approvePending, createApprovalBudgetGuard, explain, LedgerDenyUnrecorded, loadApprovalsFromDir, } from "@verax-ai/proxy";
|
|
8
9
|
import { createVerifier, readBearer, resourceMetadataUrl, wwwAuthenticate } from "./auth.js";
|
|
9
10
|
import { bumpMetric, bumpUnauthenticated } from "./metrics.js";
|
|
@@ -11,7 +12,7 @@ import { isRevokedJti } from "./revoke.js";
|
|
|
11
12
|
import { loadOrCreateSigners } from "./keys.js";
|
|
12
13
|
import { matchingInputs } from "./inputs-read.js";
|
|
13
14
|
import { readPolicySnapshots } from "./policy-store.js";
|
|
14
|
-
import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
|
|
15
|
+
import { readHeartbeat, readInstallHealthNonce, readWitnessPulse } from "./health-extras.js";
|
|
15
16
|
import { agentsWindow } from "./agents.js";
|
|
16
17
|
import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
|
|
17
18
|
import { createBodyServices, TOOL_NAMES } from "./wiring.js";
|
|
@@ -170,10 +171,48 @@ export const TOOL_META = [
|
|
|
170
171
|
},
|
|
171
172
|
];
|
|
172
173
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
174
|
+
// Node's default requestTimeout is 300s. A stalled body would otherwise stay
|
|
175
|
+
// authorised until that default. Headers are bounded tighter than the body.
|
|
176
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
177
|
+
const HEADERS_TIMEOUT_MS = 20_000;
|
|
173
178
|
// Unbounded GET /api/ledger stringified ~690 MB at 200k rows and threw Invalid string length (HTTP 500).
|
|
174
179
|
const DEFAULT_LEDGER_LIMIT = 1000;
|
|
175
180
|
const MAX_LEDGER_LIMIT = 5000;
|
|
176
181
|
const responseSlot = new AsyncLocalStorage();
|
|
182
|
+
/** `malformed` falls through so `Host: [` stays the existing bad-request. */
|
|
183
|
+
export function loopbackHostDecision(hostHeader, bindPort) {
|
|
184
|
+
if (hostHeader === undefined || hostHeader.trim() === "")
|
|
185
|
+
return "deny";
|
|
186
|
+
const raw = hostHeader.trim();
|
|
187
|
+
if (raw.startsWith("[") && !raw.includes("]"))
|
|
188
|
+
return "malformed";
|
|
189
|
+
let name = raw;
|
|
190
|
+
let port;
|
|
191
|
+
if (raw.startsWith("[")) {
|
|
192
|
+
const end = raw.indexOf("]");
|
|
193
|
+
name = raw.slice(1, end);
|
|
194
|
+
const rest = raw.slice(end + 1);
|
|
195
|
+
if (rest === "")
|
|
196
|
+
port = undefined;
|
|
197
|
+
else if (/^:[0-9]+$/.test(rest))
|
|
198
|
+
port = Number(rest.slice(1));
|
|
199
|
+
else
|
|
200
|
+
return "deny";
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const colon = raw.lastIndexOf(":");
|
|
204
|
+
if (colon > 0 && /^[0-9]+$/.test(raw.slice(colon + 1))) {
|
|
205
|
+
name = raw.slice(0, colon);
|
|
206
|
+
port = Number(raw.slice(colon + 1));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const lowered = name.toLowerCase();
|
|
210
|
+
if (lowered !== "127.0.0.1" && lowered !== "localhost" && lowered !== "::1")
|
|
211
|
+
return "deny";
|
|
212
|
+
if (port === undefined)
|
|
213
|
+
return bindPort === 80 ? "allow" : "deny";
|
|
214
|
+
return port === bindPort ? "allow" : "deny";
|
|
215
|
+
}
|
|
177
216
|
function contentLengthOverLimit(req) {
|
|
178
217
|
const raw = req.headers["content-length"];
|
|
179
218
|
if (raw === undefined)
|
|
@@ -209,6 +248,21 @@ async function readJsonBody(req, max) {
|
|
|
209
248
|
return { ok: false, bad: true };
|
|
210
249
|
}
|
|
211
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Second look at a bearer that already verified. The first look runs before
|
|
253
|
+
* the body is read; this one runs after the body and before dispatch or
|
|
254
|
+
* approval. `exp` is seconds since the epoch. A passed `exp` is dead even
|
|
255
|
+
* inside the verifier's clock tolerance: that tolerance applied at the door,
|
|
256
|
+
* and the body has since arrived. A missing `exp` is dead too.
|
|
257
|
+
*/
|
|
258
|
+
export function bearerStillLive(payload, stateDir) {
|
|
259
|
+
const jti = typeof payload.jti === "string" ? payload.jti : "";
|
|
260
|
+
if (jti === "" || isRevokedJti(stateDir, jti))
|
|
261
|
+
return false;
|
|
262
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp))
|
|
263
|
+
return false;
|
|
264
|
+
return payload.exp * 1000 > Date.now();
|
|
265
|
+
}
|
|
212
266
|
function isProtectedResourcePath(pathname, audience) {
|
|
213
267
|
const want = new URL(resourceMetadataUrl(audience)).pathname;
|
|
214
268
|
return pathname === want || pathname === "/.well-known/oauth-protected-resource";
|
|
@@ -308,7 +362,7 @@ export async function listen(config) {
|
|
|
308
362
|
throw err;
|
|
309
363
|
}
|
|
310
364
|
const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
|
|
311
|
-
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience, config.jwksFile);
|
|
365
|
+
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience, config.jwksFile, config.jwksPin);
|
|
312
366
|
const attachHandlers = (mcp) => {
|
|
313
367
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
|
|
314
368
|
mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
@@ -340,6 +394,25 @@ export async function listen(config) {
|
|
|
340
394
|
};
|
|
341
395
|
const server = createServer(async (req, res) => {
|
|
342
396
|
try {
|
|
397
|
+
if (isLoopbackHost(config.bindHost)) {
|
|
398
|
+
const hostHeader = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host;
|
|
399
|
+
const listenPort = server.address().port;
|
|
400
|
+
const hostDecision = loopbackHostDecision(hostHeader, listenPort);
|
|
401
|
+
if (hostDecision === "deny") {
|
|
402
|
+
send(res, 400, { error: "host-not-allowed" });
|
|
403
|
+
req.resume();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (req.headers.origin !== undefined) {
|
|
408
|
+
const origin = Array.isArray(req.headers.origin) ? (req.headers.origin[0] ?? "") : req.headers.origin;
|
|
409
|
+
const allowed = config.allowedOrigins ?? [];
|
|
410
|
+
if (!allowed.includes(origin)) {
|
|
411
|
+
send(res, 403, { error: "origin-not-allowed" });
|
|
412
|
+
req.resume();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
343
416
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
344
417
|
if (req.method === "POST" && contentLengthOverLimit(req)) {
|
|
345
418
|
send(res, 413, { error: "payload-too-large" });
|
|
@@ -347,6 +420,14 @@ export async function listen(config) {
|
|
|
347
420
|
return;
|
|
348
421
|
}
|
|
349
422
|
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
423
|
+
// Local mode reads VERAX_JWKS_FILE. Any process that can read that key
|
|
424
|
+
// file can mint verax:audit, so counts are not a door here: liveness only.
|
|
425
|
+
const installNonce = readInstallHealthNonce(config.stateDir);
|
|
426
|
+
const nonceField = installNonce === null ? {} : { nonce: installNonce };
|
|
427
|
+
if (config.jwksFile) {
|
|
428
|
+
send(res, 200, { ok: true, ...nonceField });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
350
431
|
const token = readBearer(req.headers.authorization);
|
|
351
432
|
let canRead = false;
|
|
352
433
|
if (token) {
|
|
@@ -367,7 +448,7 @@ export async function listen(config) {
|
|
|
367
448
|
}
|
|
368
449
|
}
|
|
369
450
|
if (!canRead) {
|
|
370
|
-
send(res, 200, { ok: true });
|
|
451
|
+
send(res, 200, { ok: true, ...nonceField });
|
|
371
452
|
return;
|
|
372
453
|
}
|
|
373
454
|
// The ledger counts its own lines as it writes them; asking it is free.
|
|
@@ -389,6 +470,7 @@ export async function listen(config) {
|
|
|
389
470
|
}
|
|
390
471
|
send(res, 200, {
|
|
391
472
|
ok: true,
|
|
473
|
+
...nonceField,
|
|
392
474
|
...counted,
|
|
393
475
|
lock: services.ledger.lockStatus(),
|
|
394
476
|
heartbeat: readHeartbeat(config.stateDir),
|
|
@@ -446,6 +528,21 @@ export async function listen(config) {
|
|
|
446
528
|
send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
|
|
447
529
|
return;
|
|
448
530
|
}
|
|
531
|
+
const refuseStaleBearer = async () => {
|
|
532
|
+
if (bearerStillLive(verified.payload, config.stateDir))
|
|
533
|
+
return false;
|
|
534
|
+
await bumpUnauthenticated(config.stateDir);
|
|
535
|
+
send(res, 401, { error: "unauthorized" }, { "www-authenticate": wwwAuthenticate(config.audience) });
|
|
536
|
+
return true;
|
|
537
|
+
};
|
|
538
|
+
// Local mode: any process that can read the key file can mint
|
|
539
|
+
// verax:approve or verax:audit, so those scopes are not honoured at all.
|
|
540
|
+
// Nothing is written; the refusal is the whole answer.
|
|
541
|
+
if (config.jwksFile &&
|
|
542
|
+
(verified.principal.scopes.has("verax:approve") || verified.principal.scopes.has("verax:audit"))) {
|
|
543
|
+
send(res, 403, { error: "local-mode-operator-scope" });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
449
546
|
try {
|
|
450
547
|
if (apiApprove) {
|
|
451
548
|
// Reading the ledger is not approving from it. The audit scope opens
|
|
@@ -456,6 +553,8 @@ export async function listen(config) {
|
|
|
456
553
|
return;
|
|
457
554
|
}
|
|
458
555
|
const parsed = await readJsonBody(req, MAX_BODY_BYTES);
|
|
556
|
+
if (await refuseStaleBearer())
|
|
557
|
+
return;
|
|
459
558
|
if (!parsed.ok) {
|
|
460
559
|
send(res, 400, { error: "bad-body" });
|
|
461
560
|
return;
|
|
@@ -504,9 +603,14 @@ export async function listen(config) {
|
|
|
504
603
|
policy: services.policy,
|
|
505
604
|
approvals,
|
|
506
605
|
now: () => Date.now(),
|
|
606
|
+
ledger: services.ledger,
|
|
507
607
|
}),
|
|
508
608
|
});
|
|
509
609
|
if (!outcome.ok) {
|
|
610
|
+
if (outcome.reason === "halted") {
|
|
611
|
+
send(res, 409, { error: "approve-halted" });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
510
614
|
const code = outcome.reason === "unknown-ref" || outcome.reason === "snapshot-missing" ? 404 : 409;
|
|
511
615
|
send(res, code, {
|
|
512
616
|
error: outcome.reason,
|
|
@@ -518,6 +622,8 @@ export async function listen(config) {
|
|
|
518
622
|
return;
|
|
519
623
|
}
|
|
520
624
|
if (apiLedger || apiInventory || contest || apiAgents) {
|
|
625
|
+
if (await refuseStaleBearer())
|
|
626
|
+
return;
|
|
521
627
|
// The audit doors hand out the whole ledger: every tenant's decisions, the
|
|
522
628
|
// inputs documents that name their principals, and the approval snapshots
|
|
523
629
|
// that carry spend arguments. `verax:read` is a brain scope, so it cannot be
|
|
@@ -612,6 +718,8 @@ export async function listen(config) {
|
|
|
612
718
|
});
|
|
613
719
|
await mcp.connect(transport);
|
|
614
720
|
const parsed = req.method === "POST" ? await readJsonBody(req, MAX_BODY_BYTES) : { ok: true, value: undefined };
|
|
721
|
+
if (await refuseStaleBearer())
|
|
722
|
+
return;
|
|
615
723
|
if (parsed.ok === false && "tooLarge" in parsed) {
|
|
616
724
|
send(res, 413, { error: "payload-too-large" });
|
|
617
725
|
req.destroy();
|
|
@@ -696,6 +804,8 @@ export async function listen(config) {
|
|
|
696
804
|
// Swallow so a bad Host cannot reject the request listener.
|
|
697
805
|
}
|
|
698
806
|
});
|
|
807
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
808
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
699
809
|
await new Promise((resolve, reject) => {
|
|
700
810
|
server.once("error", reject);
|
|
701
811
|
server.listen(config.bindPort, config.bindHost, () => resolve());
|
|
@@ -704,6 +814,18 @@ export async function listen(config) {
|
|
|
704
814
|
services.ledger.close();
|
|
705
815
|
void closeAll(sessions);
|
|
706
816
|
});
|
|
817
|
+
const bound = server.address();
|
|
818
|
+
if (!bound || typeof bound === "string") {
|
|
819
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
820
|
+
throw new Error("listen-port");
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
services.ledger.recordListenPort(bound.port);
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
827
|
+
throw err;
|
|
828
|
+
}
|
|
707
829
|
return server;
|
|
708
830
|
}
|
|
709
831
|
export { TOOL_NAMES };
|
package/dist/tools/audit.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
import { type ExplainOpts, type Ledger, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
|
-
export declare function auditExplain(call: ToolCall, ledger: Ledger, opts?: ExplainOpts
|
|
1
|
+
import { type ExplainOpts, type Ledger, type Principal, type ToolCall, type ToolResult } from "@verax-ai/proxy";
|
|
2
|
+
export declare function auditExplain(call: ToolCall, ledger: Ledger, opts?: ExplainOpts, caller?: {
|
|
3
|
+
principal: Principal;
|
|
4
|
+
stateDir: string;
|
|
5
|
+
}): Promise<ToolResult>;
|
package/dist/tools/audit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { explain, spokenReason, } from "@verax-ai/proxy";
|
|
2
|
-
|
|
1
|
+
import { explain, spokenReason, tenantKey, } from "@verax-ai/proxy";
|
|
2
|
+
import { inputsPrincipal } from "../inputs-read.js";
|
|
3
|
+
export async function auditExplain(call, ledger, opts, caller) {
|
|
3
4
|
const ref = call.arguments.ref;
|
|
4
5
|
if (typeof ref !== "string" || ref === "") {
|
|
5
6
|
return {
|
|
@@ -7,6 +8,15 @@ export async function auditExplain(call, ledger, opts) {
|
|
|
7
8
|
isError: true,
|
|
8
9
|
};
|
|
9
10
|
}
|
|
11
|
+
if (caller) {
|
|
12
|
+
const owner = await inputsPrincipal(caller.stateDir, ref);
|
|
13
|
+
if (owner !== null && tenantKey(owner) !== tenantKey(caller.principal)) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify({ error: spokenReason("tenant-mismatch") }) }],
|
|
16
|
+
isError: true,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
let result;
|
|
11
21
|
try {
|
|
12
22
|
result = await explain(ledger, ref, opts);
|
package/dist/tools/memory.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { resolve, sep } from "node:path";
|
|
5
5
|
import { canonical } from "@cedulon/core";
|
|
6
6
|
import { tenantKey } from "@verax-ai/proxy";
|
|
7
|
+
import { memoryQuotaBytes } from "../config.js";
|
|
7
8
|
function sha256Canonical(value) {
|
|
8
9
|
return createHash("sha256").update(canonical(value), "utf8").digest("hex");
|
|
9
10
|
}
|
|
10
11
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
12
|
+
/** One in-process chain per tenant memory directory. The body is one process per state dir. */
|
|
13
|
+
const memoryQuotaChain = new Map();
|
|
14
|
+
function withMemoryQuotaLock(key, fn) {
|
|
15
|
+
const previous = memoryQuotaChain.get(key) ?? Promise.resolve();
|
|
16
|
+
const run = previous.then(fn, fn);
|
|
17
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
18
|
+
memoryQuotaChain.set(key, settled);
|
|
19
|
+
void settled.then(() => {
|
|
20
|
+
if (memoryQuotaChain.get(key) === settled)
|
|
21
|
+
memoryQuotaChain.delete(key);
|
|
22
|
+
});
|
|
23
|
+
return run;
|
|
24
|
+
}
|
|
11
25
|
function jsonResult(value, isError = false) {
|
|
12
26
|
return {
|
|
13
27
|
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
@@ -135,10 +149,31 @@ export async function memoryPut(call, stateDir, principal) {
|
|
|
135
149
|
versionHash,
|
|
136
150
|
};
|
|
137
151
|
const dir = resolve(stateDir, "tenants", tenantKey(principal), "memory");
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
const payload = `${JSON.stringify(rec)}\n`;
|
|
153
|
+
return withMemoryQuotaLock(dir, async () => {
|
|
154
|
+
let used = 0;
|
|
155
|
+
try {
|
|
156
|
+
for (const name of await readdir(dir)) {
|
|
157
|
+
const file = resolve(dir, name);
|
|
158
|
+
if (file === path)
|
|
159
|
+
continue;
|
|
160
|
+
const info = await stat(file);
|
|
161
|
+
if (info.isFile())
|
|
162
|
+
used += info.size;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (err.code !== "ENOENT")
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
if (used + Buffer.byteLength(payload) > memoryQuotaBytes()) {
|
|
170
|
+
return jsonResult({ error: "memory-quota" }, true);
|
|
171
|
+
}
|
|
172
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
173
|
+
await writeFile(path, payload, {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
mode: 0o600,
|
|
176
|
+
});
|
|
177
|
+
return jsonResult({ ok: true, id, versionHash });
|
|
142
178
|
});
|
|
143
|
-
return jsonResult({ ok: true, id, versionHash });
|
|
144
179
|
}
|
package/dist/tools/message.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tenantKey } from "@verax-ai/proxy";
|
|
5
|
+
import { messageQuotaBytes } from "../config.js";
|
|
6
|
+
/** One in-process chain per tenant directory. The body is one process per state dir. */
|
|
7
|
+
const messageQuotaChain = new Map();
|
|
8
|
+
function withMessageQuotaLock(key, fn) {
|
|
9
|
+
const previous = messageQuotaChain.get(key) ?? Promise.resolve();
|
|
10
|
+
const run = previous.then(fn, fn);
|
|
11
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
12
|
+
messageQuotaChain.set(key, settled);
|
|
13
|
+
void settled.then(() => {
|
|
14
|
+
if (messageQuotaChain.get(key) === settled)
|
|
15
|
+
messageQuotaChain.delete(key);
|
|
16
|
+
});
|
|
17
|
+
return run;
|
|
18
|
+
}
|
|
5
19
|
function tenantDir(stateDir, principal) {
|
|
6
20
|
return join(stateDir, "tenants", tenantKey(principal));
|
|
7
21
|
}
|
|
@@ -32,13 +46,28 @@ export async function messageSend(call, stateDir, ref, principal) {
|
|
|
32
46
|
ref,
|
|
33
47
|
};
|
|
34
48
|
const dir = tenantDir(stateDir, principal);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
const file = join(dir, "outbox.jsonl");
|
|
50
|
+
const payload = `${JSON.stringify(row)}\n`;
|
|
51
|
+
return withMessageQuotaLock(dir, async () => {
|
|
52
|
+
let used = 0;
|
|
53
|
+
try {
|
|
54
|
+
used = statSync(file).size;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err.code !== "ENOENT")
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
if (used + Buffer.byteLength(payload) > messageQuotaBytes()) {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: JSON.stringify({ error: "message-quota" }) }],
|
|
63
|
+
isError: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
appendFileSync(file, payload, { encoding: "utf8", mode: 0o600 });
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: JSON.stringify({ queued: true, ref }) }],
|
|
70
|
+
isError: false,
|
|
71
|
+
};
|
|
39
72
|
});
|
|
40
|
-
return {
|
|
41
|
-
content: [{ type: "text", text: JSON.stringify({ queued: true, ref }) }],
|
|
42
|
-
isError: false,
|
|
43
|
-
};
|
|
44
73
|
}
|
package/dist/unlock.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export declare function pidAlive(pid: number): boolean;
|
|
2
|
-
export
|
|
2
|
+
export type LockRecord = {
|
|
3
3
|
pid: number;
|
|
4
4
|
startedAt: number;
|
|
5
|
-
|
|
5
|
+
port?: number;
|
|
6
|
+
};
|
|
7
|
+
/** A lock from before the body recorded its port has no `port`. That is still a lock. */
|
|
8
|
+
export declare function readLockFile(path: string): LockRecord | null;
|
|
6
9
|
export declare function runUnlock(stateDir: string, writeErr?: (s: string) => void, options?: {
|
|
7
10
|
force?: boolean;
|
|
8
11
|
unlink?: (path: string) => void;
|
package/dist/unlock.js
CHANGED
|
@@ -12,12 +12,17 @@ export function pidAlive(pid) {
|
|
|
12
12
|
return err.code === "EPERM";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/** A lock from before the body recorded its port has no `port`. That is still a lock. */
|
|
15
16
|
export function readLockFile(path) {
|
|
16
17
|
try {
|
|
17
18
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
18
19
|
if (typeof raw.pid !== "number" || typeof raw.startedAt !== "number")
|
|
19
20
|
return null;
|
|
20
|
-
|
|
21
|
+
const record = { pid: raw.pid, startedAt: raw.startedAt };
|
|
22
|
+
if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) {
|
|
23
|
+
record.port = raw.port;
|
|
24
|
+
}
|
|
25
|
+
return record;
|
|
21
26
|
}
|
|
22
27
|
catch {
|
|
23
28
|
return null;
|