@verax-ai/body 0.1.3 → 0.2.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 CHANGED
@@ -35,7 +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
+ | `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?, trust? }` 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 stdio child requires `"trust": "same-user"` (it runs as the same user as the body and can read the signing keys); an HTTP child does not ask for that field and must not carry it. This is a breaking change: existing stdio documents do not open until the field is added. 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. |
39
39
 
40
40
  `verax doctor` names what is missing. A misconfigured body exits with code 78
41
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/doctor.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { indexCoverage, listPieceFiles } from "@verax-ai/proxy";
4
4
  import { loadConfig, isLoopbackHost } from "./config.js";
5
+ import { parseDownstreamDocument } from "./downstream.js";
5
6
  import { pidAlive, readLockFile } from "./unlock.js";
6
7
  const SECRET_RE = /sk-|-----BEGIN|Bearer |eyJ[A-Za-z0-9_-]{10,}\./;
7
8
  const SECRET_NAME = /^(VERAX_DEV_TOKEN|.*_(TOKEN|SECRET|KEY))$/;
@@ -184,6 +185,33 @@ export function runDoctor(env, argv) {
184
185
  });
185
186
  }
186
187
  }
188
+ const downstreamPath = env.VERAX_DOWNSTREAM?.trim() ?? "";
189
+ if (downstreamPath !== "") {
190
+ try {
191
+ const specs = parseDownstreamDocument(readFileSync(downstreamPath, "utf8"));
192
+ const stdio = specs.filter((s) => s.command !== undefined);
193
+ const http = specs.filter((s) => s.url !== undefined);
194
+ checks.push({
195
+ id: "downstream-document",
196
+ level: "ok",
197
+ detail: `${specs.length} child(ren): ${stdio.length} stdio, ${http.length} http`,
198
+ });
199
+ for (const spec of stdio) {
200
+ checks.push({
201
+ id: `downstream-stdio:${spec.prefix}`,
202
+ level: "warn",
203
+ detail: `stdio child ${spec.prefix} runs as the same user as the body and can read the body's signing keys (keys/*.pem). An untrusted child should be reached over HTTP, ideally on a separate machine or under a separate operating-system user.`,
204
+ });
205
+ }
206
+ }
207
+ catch (err) {
208
+ checks.push({
209
+ id: "downstream-document",
210
+ level: "fail",
211
+ detail: err instanceof Error ? err.message : "downstream-document-unreadable",
212
+ });
213
+ }
214
+ }
187
215
  return checks;
188
216
  }
189
217
  const DEFAULT_REDIRECTS = ["http://127.0.0.1:5173/", "http://127.0.0.1:4173/"];
@@ -18,6 +18,12 @@ export type DownstreamSpec = {
18
18
  /** HTTP: headers the operator sends to the child, such as its own bearer. */
19
19
  headers?: Record<string, string>;
20
20
  timeoutMs?: number;
21
+ /**
22
+ * stdio only. The operator wrote that this child runs as the same user
23
+ * as the body and can read the body's signing keys. Required on stdio;
24
+ * forbidden on HTTP.
25
+ */
26
+ trust?: "same-user";
21
27
  };
22
28
  export type DownstreamTool = {
23
29
  name: string;
@@ -6,6 +6,10 @@ const PREFIX_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
6
6
  const CHILD_TOOL_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
7
7
  /** Prefixed name a caller may put on extraTools: `prefix.childName`. */
8
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;
9
13
  export class DownstreamCallError extends Error {
10
14
  prefix;
11
15
  tool;
@@ -25,6 +29,18 @@ export function prefixedName(prefix, childName) {
25
29
  export function extraToolNameOk(name) {
26
30
  return EXTRA_NAME_RE.test(name);
27
31
  }
32
+ /**
33
+ * The operator wrote `"trust": "same-user"` on a stdio child. Missing ack
34
+ * refuses start. One rule, two call sites — do not duplicate the predicate.
35
+ */
36
+ function assertStdioTrusted(spec) {
37
+ if (spec.command !== undefined && spec.trust !== "same-user") {
38
+ // The prefix is the operator's own name for the child and names WHICH entry
39
+ // to fix in a document with several. Nothing else about the child goes in:
40
+ // not the command, the arguments or the environment.
41
+ throw new Error(`downstream-stdio-trust-required:${spec.prefix}`);
42
+ }
43
+ }
28
44
  export function parseDownstreamJson(raw) {
29
45
  let parsed;
30
46
  try {
@@ -113,6 +129,16 @@ export function parseDownstreamJson(raw) {
113
129
  }
114
130
  spec.timeoutMs = rec.timeoutMs;
115
131
  }
132
+ if (rec.trust !== undefined) {
133
+ // HTTP + any trust, or a value other than "same-user", is the same
134
+ // refusal: the field is not a claim about a remote host, and it is
135
+ // not a free-form string.
136
+ if (spec.url !== undefined || rec.trust !== "same-user") {
137
+ throw new Error("downstream-trust-invalid");
138
+ }
139
+ spec.trust = "same-user";
140
+ }
141
+ assertStdioTrusted(spec);
116
142
  return spec;
117
143
  }
118
144
  /**
@@ -171,6 +197,8 @@ function stdioTransport(spec) {
171
197
  if (spec.command === undefined || spec.command.trim() === "") {
172
198
  throw new Error("downstream-command-invalid");
173
199
  }
200
+ // Last check before the spawn: openDownstream can be called without parse.
201
+ assertStdioTrusted(spec);
174
202
  // Safe inherit + operator overlay. Not process.env: that would copy
175
203
  // VERAX_* tokens the body already holds.
176
204
  const env = { ...getDefaultEnvironment(), ...(spec.env ?? {}) };
@@ -196,9 +224,47 @@ function httpTransport(spec) {
196
224
  if (spec.url === undefined)
197
225
  throw new Error("downstream-url-invalid");
198
226
  return new StreamableHTTPClientTransport(new URL(spec.url), {
199
- ...(spec.headers ? { requestInit: { headers: spec.headers } } : {}),
227
+ requestInit: {
228
+ // The child's address is the operator's document, not a place the
229
+ // child may name. Following a redirect would rewrite that document
230
+ // at run time. The egress list does not see it: egress is for hosts
231
+ // the brain picks.
232
+ redirect: "error",
233
+ ...(spec.headers ? { headers: spec.headers } : {}),
234
+ },
200
235
  });
201
236
  }
237
+ /**
238
+ * `connect` does not take a timeout option. Race it, close the transport
239
+ * when the clock wins, and drop the timer so a settled attach cannot keep
240
+ * the process alive.
241
+ */
242
+ async function connectWithDeadline(client, transport, timeoutMs, prefix) {
243
+ let timer;
244
+ try {
245
+ await new Promise((resolve, reject) => {
246
+ timer = setTimeout(() => {
247
+ void shut(client, transport);
248
+ reject(new Error(`downstream-attach-timeout:${prefix}`));
249
+ }, timeoutMs);
250
+ client.connect(transport).then(resolve, reject);
251
+ });
252
+ }
253
+ finally {
254
+ if (timer !== undefined)
255
+ clearTimeout(timer);
256
+ }
257
+ }
258
+ function attachTimeoutError(err, prefix) {
259
+ const msg = err instanceof Error ? err.message : String(err);
260
+ if (msg.startsWith("downstream-attach-timeout:")) {
261
+ return err instanceof Error ? err : new Error(`downstream-attach-timeout:${prefix}`);
262
+ }
263
+ if (/timed?\s*out|timeout/i.test(msg)) {
264
+ return new Error(`downstream-attach-timeout:${prefix}`);
265
+ }
266
+ return err instanceof Error ? err : new Error(msg);
267
+ }
202
268
  export async function openDownstream(spec) {
203
269
  if (!PREFIX_RE.test(spec.prefix)) {
204
270
  throw new Error("downstream-prefix-invalid");
@@ -214,12 +280,16 @@ export async function openDownstream(spec) {
214
280
  const client = new Client({ name: "verax-downstream", version: "0.0.0" });
215
281
  let listed;
216
282
  try {
217
- await client.connect(transport);
218
- listed = await client.listTools();
283
+ await connectWithDeadline(client, transport, timeoutMs, spec.prefix);
284
+ listed = await client.listTools(undefined, { timeout: timeoutMs });
219
285
  }
220
286
  catch (err) {
221
287
  await shut(client, transport);
222
- throw err;
288
+ throw attachTimeoutError(err, spec.prefix);
289
+ }
290
+ if (listed.tools.length > MAX_CHILD_TOOLS) {
291
+ await shut(client, transport);
292
+ throw new Error(`downstream-too-many-tools:${spec.prefix}:${listed.tools.length}`);
223
293
  }
224
294
  const tools = [];
225
295
  const seen = new Set();
@@ -228,6 +298,15 @@ export async function openDownstream(spec) {
228
298
  await shut(client, transport);
229
299
  throw new Error(`downstream-child-name-invalid:${tool.name}`);
230
300
  }
301
+ const toolBytes = JSON.stringify({
302
+ name: tool.name,
303
+ description: tool.description,
304
+ inputSchema: tool.inputSchema,
305
+ }).length;
306
+ if (toolBytes > MAX_TOOL_BYTES) {
307
+ await shut(client, transport);
308
+ throw new Error(`downstream-tool-too-large:${spec.prefix}.${tool.name}:${toolBytes}`);
309
+ }
231
310
  const name = prefixedName(spec.prefix, tool.name);
232
311
  if (seen.has(name)) {
233
312
  await shut(client, transport);
@@ -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.3",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -22,8 +22,8 @@
22
22
  ],
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.30.0",
25
- "@verax-ai/inventory": "^0.1.0",
26
- "@verax-ai/proxy": "^0.1.0",
25
+ "@verax-ai/inventory": "^0.2.0",
26
+ "@verax-ai/proxy": "^0.2.0",
27
27
  "jose": "^6.2.11"
28
28
  },
29
29
  "description": "VERAX body: an MCP server that decides, records and proves what an agent did.",