@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 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
  }
@@ -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
- command: string;
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>;
@@ -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
- if (typeof rec.command !== "string" || rec.command.trim() === "") {
43
- throw new Error("downstream-command-invalid");
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
- export async function openDownstream(spec) {
104
- if (!PREFIX_RE.test(spec.prefix)) {
105
- throw new Error("downstream-prefix-invalid");
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.connect(transport);
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
- const services = createBodyServices({
227
- stateDir: config.stateDir,
228
- policyFile: config.policyFile,
229
- recordSigner: signers.recordSigner,
230
- effectSigner: signers.effectSigner,
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: TOOL_META }));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/body",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {