@verax-ai/body 0.1.2 → 0.1.4
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/cli.js +6 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +2 -0
- package/dist/downstream.d.ts +22 -1
- package/dist/downstream.js +158 -12
- package/dist/server.js +90 -7
- package/dist/verify-cli.d.ts +5 -0
- package/dist/verify-cli.js +91 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ Bearer token the configured issuer did not sign.
|
|
|
35
35
|
| `VERAX_POLICY_FILE` | yes | Policy the gate applies. `@verax-ai/proxy` ships `policy/default.json`, which denies what it does not name. |
|
|
36
36
|
| `VERAX_BIND` | no | `host:port` to listen on. Default `127.0.0.1:8787`; anything but loopback needs `VERAX_TLS_TERMINATED=1`. |
|
|
37
37
|
| `VERAX_INVENTORY_FILE` | no | Roster document the body serves. The format is `@verax-ai/inventory`. |
|
|
38
|
+
| `VERAX_DOWNSTREAM` | no | Path to a JSON document naming MCP servers to put behind this gate: one `{ prefix, command?, args?, cwd?, env?, url?, headers?, timeoutMs? }` or an array of them. Each child names exactly one way in — `command` to spawn it over stdio, or `url` to reach one already running over Streamable HTTP, with `headers` for its own bearer. A path and not the JSON itself, because the document may name a key in `env` or `headers`. Their tools are served as `prefix.childName` and need an exact policy rule under that name; a named child that cannot be opened stops the body. |
|
|
38
39
|
|
|
39
40
|
`verax doctor` names what is missing. A misconfigured body exits with code 78
|
|
40
41
|
before it listens.
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runHalt } from "./halt.js";
|
|
|
9
9
|
import { main } from "./main.js";
|
|
10
10
|
import { runOperator } from "./operator-cli.js";
|
|
11
11
|
import { runReconcile } from "./reconcile-cli.js";
|
|
12
|
+
import { runVerify } from "./verify-cli.js";
|
|
12
13
|
import { runUnlock } from "./unlock.js";
|
|
13
14
|
import { runWitness } from "./witness.js";
|
|
14
15
|
const HELP = `verax - the body an agent asks before it acts, and the ledger it answers from.
|
|
@@ -20,6 +21,8 @@ Usage: verax <command> [options]
|
|
|
20
21
|
approve <args> approve a waiting request from this machine
|
|
21
22
|
operator <args> enrol an operator and manage their passkeys
|
|
22
23
|
reconcile <args> compare the ledger against a statement
|
|
24
|
+
verify <stateDir> read a ledger back without a body: signatures, chain,
|
|
25
|
+
effect binding, and which key answered
|
|
23
26
|
witness <stateDir> run the witness alongside a body
|
|
24
27
|
halt <stateDir> stop the body from allowing anything further
|
|
25
28
|
unlock [--force] <stateDir> clear a stale ledger lock
|
|
@@ -98,6 +101,9 @@ if (argv[0] === "doctor") {
|
|
|
98
101
|
if (argv[0] === "reconcile") {
|
|
99
102
|
process.exit(runReconcile(argv));
|
|
100
103
|
}
|
|
104
|
+
if (argv[0] === "verify") {
|
|
105
|
+
process.exit(await runVerify(argv.slice(1)));
|
|
106
|
+
}
|
|
101
107
|
if (argv[0] === "desktop") {
|
|
102
108
|
process.exit(await desktopMain(argv));
|
|
103
109
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export type BodyConfig = {
|
|
|
10
10
|
tlsTerminated: boolean;
|
|
11
11
|
/** Path to an inventory JSON file. Missing file is absence, not a fault. */
|
|
12
12
|
inventoryFile?: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Path to the `VERAX_DOWNSTREAM` document. A path, not the JSON itself: the
|
|
15
|
+
* document may name a key in `env`, and a secret does not belong in the
|
|
16
|
+
* process environment of every child the operator later starts. Unset means
|
|
17
|
+
* no downstream; a named file that cannot be attached stops the body.
|
|
18
|
+
*/
|
|
19
|
+
downstreamFile?: string | null;
|
|
13
20
|
};
|
|
14
21
|
export type ConfigResult = {
|
|
15
22
|
ok: true;
|
package/dist/config.js
CHANGED
|
@@ -43,6 +43,7 @@ export function loadConfig(env) {
|
|
|
43
43
|
return { ok: false, code: EX_CONFIG, reason: "missing VERAX_POLICY_FILE" };
|
|
44
44
|
}
|
|
45
45
|
const inventoryRaw = env.VERAX_INVENTORY_FILE?.trim() ?? "";
|
|
46
|
+
const downstreamRaw = env.VERAX_DOWNSTREAM?.trim() ?? "";
|
|
46
47
|
return {
|
|
47
48
|
ok: true,
|
|
48
49
|
value: {
|
|
@@ -55,6 +56,7 @@ export function loadConfig(env) {
|
|
|
55
56
|
policyFile,
|
|
56
57
|
tlsTerminated,
|
|
57
58
|
inventoryFile: inventoryRaw === "" ? null : inventoryRaw,
|
|
59
|
+
downstreamFile: downstreamRaw === "" ? null : downstreamRaw,
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
}
|
package/dist/downstream.d.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import type { Principal, ToolCall, ToolResult } from "@verax-ai/proxy";
|
|
2
2
|
export type DownstreamToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
|
|
3
|
+
/**
|
|
4
|
+
* One child, reached one of two ways. `command` spawns it over stdio;
|
|
5
|
+
* `url` speaks Streamable HTTP to one already running. Exactly one of the
|
|
6
|
+
* two: a document naming both does not say which the operator meant, and a
|
|
7
|
+
* document naming neither says nothing at all.
|
|
8
|
+
*/
|
|
3
9
|
export type DownstreamSpec = {
|
|
4
10
|
prefix: string;
|
|
5
|
-
|
|
11
|
+
/** stdio: the process to start. */
|
|
12
|
+
command?: string;
|
|
6
13
|
args?: string[];
|
|
7
14
|
cwd?: string;
|
|
8
15
|
env?: Record<string, string>;
|
|
16
|
+
/** HTTP: the MCP endpoint of a server already running. */
|
|
17
|
+
url?: string;
|
|
18
|
+
/** HTTP: headers the operator sends to the child, such as its own bearer. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
9
20
|
timeoutMs?: number;
|
|
10
21
|
};
|
|
11
22
|
export type DownstreamTool = {
|
|
12
23
|
name: string;
|
|
24
|
+
/** The child's own description, as `tools/list` gave it. */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** The child's own input schema, republished unchanged. */
|
|
27
|
+
inputSchema?: unknown;
|
|
13
28
|
fn: DownstreamToolFn;
|
|
14
29
|
};
|
|
15
30
|
export type DownstreamSession = {
|
|
@@ -25,4 +40,10 @@ export declare class DownstreamCallError extends Error {
|
|
|
25
40
|
export declare function prefixedName(prefix: string, childName: string): string;
|
|
26
41
|
export declare function extraToolNameOk(name: string): boolean;
|
|
27
42
|
export declare function parseDownstreamJson(raw: string): DownstreamSpec;
|
|
43
|
+
/**
|
|
44
|
+
* The `VERAX_DOWNSTREAM` document: one child, or an array of them. An empty
|
|
45
|
+
* array is a document that attaches nothing, which is not the same as no
|
|
46
|
+
* document at all — the operator wrote it, so it is honoured.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseDownstreamDocument(raw: string): DownstreamSpec[];
|
|
28
49
|
export declare function openDownstream(spec: DownstreamSpec): Promise<DownstreamSession>;
|
package/dist/downstream.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
5
|
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
5
6
|
const CHILD_TOOL_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
|
|
6
7
|
/** Prefixed name a caller may put on extraTools: `prefix.childName`. */
|
|
7
8
|
const EXTRA_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}\.[A-Za-z][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
// Sanity bound, not a capacity claim. A child that lists more than this is
|
|
10
|
+
// hostile or broken; the body is not a registry for thousands of names.
|
|
11
|
+
const MAX_CHILD_TOOLS = 256;
|
|
12
|
+
const MAX_TOOL_BYTES = 64 * 1024;
|
|
8
13
|
export class DownstreamCallError extends Error {
|
|
9
14
|
prefix;
|
|
10
15
|
tool;
|
|
@@ -39,10 +44,49 @@ export function parseDownstreamJson(raw) {
|
|
|
39
44
|
if (typeof rec.prefix !== "string" || !PREFIX_RE.test(rec.prefix)) {
|
|
40
45
|
throw new Error("downstream-prefix-invalid");
|
|
41
46
|
}
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
const cmdVar = rec.command !== undefined;
|
|
48
|
+
const urlVar = rec.url !== undefined;
|
|
49
|
+
if (cmdVar && urlVar)
|
|
50
|
+
throw new Error("downstream-transport-ambiguous");
|
|
51
|
+
if (!cmdVar && !urlVar)
|
|
52
|
+
throw new Error("downstream-transport-missing");
|
|
53
|
+
const spec = { prefix: rec.prefix };
|
|
54
|
+
if (cmdVar) {
|
|
55
|
+
if (typeof rec.command !== "string" || rec.command.trim() === "") {
|
|
56
|
+
throw new Error("downstream-command-invalid");
|
|
57
|
+
}
|
|
58
|
+
spec.command = rec.command;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (typeof rec.url !== "string" || rec.url.trim() === "") {
|
|
62
|
+
throw new Error("downstream-url-invalid");
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(rec.url);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error("downstream-url-invalid");
|
|
70
|
+
}
|
|
71
|
+
// Only the two schemes the SDK transport speaks. A `file:` or `ftp:` URL
|
|
72
|
+
// would be read as a fetch target by something later, so it is refused here.
|
|
73
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
74
|
+
throw new Error("downstream-url-invalid");
|
|
75
|
+
}
|
|
76
|
+
spec.url = rec.url;
|
|
77
|
+
if (rec.headers !== undefined) {
|
|
78
|
+
if (rec.headers === null || typeof rec.headers !== "object" || Array.isArray(rec.headers)) {
|
|
79
|
+
throw new Error("downstream-headers-invalid");
|
|
80
|
+
}
|
|
81
|
+
const headers = {};
|
|
82
|
+
for (const [k, v] of Object.entries(rec.headers)) {
|
|
83
|
+
if (typeof v !== "string")
|
|
84
|
+
throw new Error("downstream-headers-invalid");
|
|
85
|
+
headers[k] = v;
|
|
86
|
+
}
|
|
87
|
+
spec.headers = headers;
|
|
88
|
+
}
|
|
44
89
|
}
|
|
45
|
-
const spec = { prefix: rec.prefix, command: rec.command };
|
|
46
90
|
if (rec.args !== undefined) {
|
|
47
91
|
if (!Array.isArray(rec.args) || rec.args.some((a) => typeof a !== "string")) {
|
|
48
92
|
throw new Error("downstream-args-invalid");
|
|
@@ -75,6 +119,32 @@ export function parseDownstreamJson(raw) {
|
|
|
75
119
|
}
|
|
76
120
|
return spec;
|
|
77
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The `VERAX_DOWNSTREAM` document: one child, or an array of them. An empty
|
|
124
|
+
* array is a document that attaches nothing, which is not the same as no
|
|
125
|
+
* document at all — the operator wrote it, so it is honoured.
|
|
126
|
+
*/
|
|
127
|
+
export function parseDownstreamDocument(raw) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error("downstream-json-invalid");
|
|
134
|
+
}
|
|
135
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
136
|
+
const specs = [];
|
|
137
|
+
const prefixes = new Set();
|
|
138
|
+
for (const item of items) {
|
|
139
|
+
const spec = parseDownstreamJson(JSON.stringify(item));
|
|
140
|
+
if (prefixes.has(spec.prefix)) {
|
|
141
|
+
throw new Error(`downstream-prefix-duplicate:${spec.prefix}`);
|
|
142
|
+
}
|
|
143
|
+
prefixes.add(spec.prefix);
|
|
144
|
+
specs.push(spec);
|
|
145
|
+
}
|
|
146
|
+
return specs;
|
|
147
|
+
}
|
|
78
148
|
// The SDK result is a union (current shape with an index signature, or the
|
|
79
149
|
// legacy { toolResult } shape), so it is narrowed here rather than trusted.
|
|
80
150
|
function asTextResult(raw) {
|
|
@@ -100,14 +170,11 @@ async function shut(client, transport) {
|
|
|
100
170
|
await client.close().catch(() => undefined);
|
|
101
171
|
await transport.close().catch(() => undefined);
|
|
102
172
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
if (spec.command.trim() === "") {
|
|
173
|
+
/** Spawns the child. Its stderr is drained so a chatty child cannot block it. */
|
|
174
|
+
function stdioTransport(spec) {
|
|
175
|
+
if (spec.command === undefined || spec.command.trim() === "") {
|
|
108
176
|
throw new Error("downstream-command-invalid");
|
|
109
177
|
}
|
|
110
|
-
const timeoutMs = spec.timeoutMs ?? 10_000;
|
|
111
178
|
// Safe inherit + operator overlay. Not process.env: that would copy
|
|
112
179
|
// VERAX_* tokens the body already holds.
|
|
113
180
|
const env = { ...getDefaultEnvironment(), ...(spec.env ?? {}) };
|
|
@@ -122,15 +189,83 @@ export async function openDownstream(spec) {
|
|
|
122
189
|
const stderr = transport.stderr;
|
|
123
190
|
if (stderr instanceof Readable)
|
|
124
191
|
stderr.resume();
|
|
192
|
+
return transport;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Talks to a server already running. The headers are the operator's, from the
|
|
196
|
+
* document — the body's own bearer is never forwarded, for the same
|
|
197
|
+
* confused-deputy reason the stdio child does not inherit `VERAX_*`.
|
|
198
|
+
*/
|
|
199
|
+
function httpTransport(spec) {
|
|
200
|
+
if (spec.url === undefined)
|
|
201
|
+
throw new Error("downstream-url-invalid");
|
|
202
|
+
return new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
203
|
+
requestInit: {
|
|
204
|
+
// The child's address is the operator's document, not a place the
|
|
205
|
+
// child may name. Following a redirect would rewrite that document
|
|
206
|
+
// at run time. The egress list does not see it: egress is for hosts
|
|
207
|
+
// the brain picks.
|
|
208
|
+
redirect: "error",
|
|
209
|
+
...(spec.headers ? { headers: spec.headers } : {}),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* `connect` does not take a timeout option. Race it, close the transport
|
|
215
|
+
* when the clock wins, and drop the timer so a settled attach cannot keep
|
|
216
|
+
* the process alive.
|
|
217
|
+
*/
|
|
218
|
+
async function connectWithDeadline(client, transport, timeoutMs, prefix) {
|
|
219
|
+
let timer;
|
|
220
|
+
try {
|
|
221
|
+
await new Promise((resolve, reject) => {
|
|
222
|
+
timer = setTimeout(() => {
|
|
223
|
+
void shut(client, transport);
|
|
224
|
+
reject(new Error(`downstream-attach-timeout:${prefix}`));
|
|
225
|
+
}, timeoutMs);
|
|
226
|
+
client.connect(transport).then(resolve, reject);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (timer !== undefined)
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function attachTimeoutError(err, prefix) {
|
|
235
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
236
|
+
if (msg.startsWith("downstream-attach-timeout:")) {
|
|
237
|
+
return err instanceof Error ? err : new Error(`downstream-attach-timeout:${prefix}`);
|
|
238
|
+
}
|
|
239
|
+
if (/timed?\s*out|timeout/i.test(msg)) {
|
|
240
|
+
return new Error(`downstream-attach-timeout:${prefix}`);
|
|
241
|
+
}
|
|
242
|
+
return err instanceof Error ? err : new Error(msg);
|
|
243
|
+
}
|
|
244
|
+
export async function openDownstream(spec) {
|
|
245
|
+
if (!PREFIX_RE.test(spec.prefix)) {
|
|
246
|
+
throw new Error("downstream-prefix-invalid");
|
|
247
|
+
}
|
|
248
|
+
if (spec.command !== undefined && spec.url !== undefined) {
|
|
249
|
+
throw new Error("downstream-transport-ambiguous");
|
|
250
|
+
}
|
|
251
|
+
if (spec.command === undefined && spec.url === undefined) {
|
|
252
|
+
throw new Error("downstream-transport-missing");
|
|
253
|
+
}
|
|
254
|
+
const timeoutMs = spec.timeoutMs ?? 10_000;
|
|
255
|
+
const transport = spec.url !== undefined ? httpTransport(spec) : stdioTransport(spec);
|
|
125
256
|
const client = new Client({ name: "verax-downstream", version: "0.0.0" });
|
|
126
257
|
let listed;
|
|
127
258
|
try {
|
|
128
|
-
await client.
|
|
129
|
-
listed = await client.listTools();
|
|
259
|
+
await connectWithDeadline(client, transport, timeoutMs, spec.prefix);
|
|
260
|
+
listed = await client.listTools(undefined, { timeout: timeoutMs });
|
|
130
261
|
}
|
|
131
262
|
catch (err) {
|
|
132
263
|
await shut(client, transport);
|
|
133
|
-
throw err;
|
|
264
|
+
throw attachTimeoutError(err, spec.prefix);
|
|
265
|
+
}
|
|
266
|
+
if (listed.tools.length > MAX_CHILD_TOOLS) {
|
|
267
|
+
await shut(client, transport);
|
|
268
|
+
throw new Error(`downstream-too-many-tools:${spec.prefix}:${listed.tools.length}`);
|
|
134
269
|
}
|
|
135
270
|
const tools = [];
|
|
136
271
|
const seen = new Set();
|
|
@@ -139,6 +274,15 @@ export async function openDownstream(spec) {
|
|
|
139
274
|
await shut(client, transport);
|
|
140
275
|
throw new Error(`downstream-child-name-invalid:${tool.name}`);
|
|
141
276
|
}
|
|
277
|
+
const toolBytes = JSON.stringify({
|
|
278
|
+
name: tool.name,
|
|
279
|
+
description: tool.description,
|
|
280
|
+
inputSchema: tool.inputSchema,
|
|
281
|
+
}).length;
|
|
282
|
+
if (toolBytes > MAX_TOOL_BYTES) {
|
|
283
|
+
await shut(client, transport);
|
|
284
|
+
throw new Error(`downstream-tool-too-large:${spec.prefix}.${tool.name}:${toolBytes}`);
|
|
285
|
+
}
|
|
142
286
|
const name = prefixedName(spec.prefix, tool.name);
|
|
143
287
|
if (seen.has(name)) {
|
|
144
288
|
await shut(client, transport);
|
|
@@ -148,6 +292,8 @@ export async function openDownstream(spec) {
|
|
|
148
292
|
const childName = tool.name;
|
|
149
293
|
tools.push({
|
|
150
294
|
name,
|
|
295
|
+
...(typeof tool.description === "string" ? { description: tool.description } : {}),
|
|
296
|
+
...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
|
|
151
297
|
fn: async (call) => {
|
|
152
298
|
let raw;
|
|
153
299
|
try {
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -14,6 +15,7 @@ import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
|
|
|
14
15
|
import { agentsWindow } from "./agents.js";
|
|
15
16
|
import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
|
|
16
17
|
import { createBodyServices, TOOL_NAMES } from "./wiring.js";
|
|
18
|
+
import { openDownstream, parseDownstreamDocument, } from "./downstream.js";
|
|
17
19
|
// What a brain reads before it calls. Each description says what the tool is
|
|
18
20
|
// for, what it does and does not do, what the gate may answer, and what comes
|
|
19
21
|
// back; each parameter says its format and its bounds. The answers named here
|
|
@@ -221,17 +223,94 @@ function send(res, status, body, headers) {
|
|
|
221
223
|
});
|
|
222
224
|
res.end(text);
|
|
223
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
|
+
}
|
|
224
289
|
export async function listen(config) {
|
|
225
290
|
const signers = loadOrCreateSigners(config.stateDir);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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)];
|
|
232
311
|
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
|
|
233
312
|
const attachHandlers = (mcp) => {
|
|
234
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools:
|
|
313
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
|
|
235
314
|
mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
236
315
|
const auth = extra?.authInfo;
|
|
237
316
|
const scopes = new Set(auth?.scopes ?? []);
|
|
@@ -315,6 +394,9 @@ export async function listen(config) {
|
|
|
315
394
|
heartbeat: readHeartbeat(config.stateDir),
|
|
316
395
|
witness: readWitnessPulse(config.stateDir),
|
|
317
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),
|
|
318
400
|
});
|
|
319
401
|
return;
|
|
320
402
|
}
|
|
@@ -620,6 +702,7 @@ export async function listen(config) {
|
|
|
620
702
|
});
|
|
621
703
|
server.on("close", () => {
|
|
622
704
|
services.ledger.close();
|
|
705
|
+
void closeAll(sessions);
|
|
623
706
|
});
|
|
624
707
|
return server;
|
|
625
708
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type VerifyResult } from "@verax-ai/proxy";
|
|
2
|
+
export declare const EX_VERIFY_FAILED = 1;
|
|
3
|
+
/** Lines a person reads. The trust line is never omitted. */
|
|
4
|
+
export declare function renderVerify(r: VerifyResult): string;
|
|
5
|
+
export declare function runVerify(argv: readonly string[], out?: (s: string) => void): Promise<number>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `verax verify <stateDir>` — read a ledger back without a body.
|
|
3
|
+
*
|
|
4
|
+
* The command exists for the moment a customer stops being a customer. A
|
|
5
|
+
* ledger that only the vendor's running service can read is not evidence a
|
|
6
|
+
* buyer holds; it is evidence they rent. This reads the directory on its own,
|
|
7
|
+
* with nothing listening and nothing on the network, and says what still
|
|
8
|
+
* holds together.
|
|
9
|
+
*
|
|
10
|
+
* It refuses to flatten four separate questions into one word. Signatures,
|
|
11
|
+
* the chain, the binding between effects and decisions, and — the one worth
|
|
12
|
+
* the most — **which key** answered. Verifying against the key lying in the
|
|
13
|
+
* same directory proves the files agree with each other and nothing else;
|
|
14
|
+
* that line is printed every time, not buried in a flag.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { verifyLedger } from "@verax-ai/proxy";
|
|
18
|
+
export const EX_VERIFY_FAILED = 1;
|
|
19
|
+
function usage() {
|
|
20
|
+
return [
|
|
21
|
+
"usage: verax verify <stateDir> [--key <public.pem>] [--json]",
|
|
22
|
+
"",
|
|
23
|
+
" <stateDir> the directory holding decisions.jsonl and effects.jsonl",
|
|
24
|
+
" --key <file> verify against a public key you hold, instead of the one",
|
|
25
|
+
" the records carry. This is the difference between",
|
|
26
|
+
" 'these files agree with each other' and 'these files",
|
|
27
|
+
" were signed by the key I was given'.",
|
|
28
|
+
" --json machine-readable result on stdout",
|
|
29
|
+
"",
|
|
30
|
+
"Exit code is 0 when the ledger verifies and 1 when it does not.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
/** Lines a person reads. The trust line is never omitted. */
|
|
34
|
+
export function renderVerify(r) {
|
|
35
|
+
const lines = [];
|
|
36
|
+
lines.push(`ledger ${r.directory}`);
|
|
37
|
+
lines.push(`decisions ${r.decisions}`);
|
|
38
|
+
lines.push(`effects ${r.effects} (${r.effectsBound} bound to a decision, ${r.effectsOrphaned} with none)`);
|
|
39
|
+
lines.push(`signatures ${r.signaturesValid} verify, ${r.signaturesInvalid} do not`);
|
|
40
|
+
lines.push(`chain ${r.chainBreakAt === null ? "unbroken" : `breaks at record ${r.chainBreakAt}`}`);
|
|
41
|
+
lines.push(`verified with ${r.trust.source === "pinned"
|
|
42
|
+
? "a key you supplied"
|
|
43
|
+
: r.trust.source === "in-ledger"
|
|
44
|
+
? "the key carried in these files"
|
|
45
|
+
: "no key"}`);
|
|
46
|
+
lines.push(` ${r.trust.note}`);
|
|
47
|
+
if (r.problems.length > 0) {
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push("problems:");
|
|
50
|
+
for (const p of r.problems.slice(0, 50))
|
|
51
|
+
lines.push(` - ${p}`);
|
|
52
|
+
if (r.problems.length > 50)
|
|
53
|
+
lines.push(` … and ${r.problems.length - 50} more`);
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(r.ok ? "VERIFIED" : "NOT VERIFIED");
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
export async function runVerify(argv, out = (s) => process.stdout.write(`${s}\n`)) {
|
|
60
|
+
const args = [...argv];
|
|
61
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
62
|
+
out(usage());
|
|
63
|
+
return args.length === 0 ? EX_VERIFY_FAILED : 0;
|
|
64
|
+
}
|
|
65
|
+
const json = args.includes("--json");
|
|
66
|
+
let publicKeyPem;
|
|
67
|
+
const keyAt = args.indexOf("--key");
|
|
68
|
+
if (keyAt !== -1) {
|
|
69
|
+
const path = args[keyAt + 1];
|
|
70
|
+
if (!path || path.startsWith("-")) {
|
|
71
|
+
out("verify: --key needs a file path");
|
|
72
|
+
return EX_VERIFY_FAILED;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
publicKeyPem = readFileSync(path, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
out(`verify: cannot read key file ${path}`);
|
|
79
|
+
return EX_VERIFY_FAILED;
|
|
80
|
+
}
|
|
81
|
+
args.splice(keyAt, 2);
|
|
82
|
+
}
|
|
83
|
+
const dir = args.find((a) => !a.startsWith("-"));
|
|
84
|
+
if (!dir) {
|
|
85
|
+
out(usage());
|
|
86
|
+
return EX_VERIFY_FAILED;
|
|
87
|
+
}
|
|
88
|
+
const result = await verifyLedger(dir, publicKeyPem ? { publicKeyPem } : {});
|
|
89
|
+
out(json ? JSON.stringify(result, null, 2) : renderVerify(result));
|
|
90
|
+
return result.ok ? 0 : EX_VERIFY_FAILED;
|
|
91
|
+
}
|