@verax-ai/body 0.1.3 → 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/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
  }
@@ -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;
@@ -196,9 +200,47 @@ function httpTransport(spec) {
196
200
  if (spec.url === undefined)
197
201
  throw new Error("downstream-url-invalid");
198
202
  return new StreamableHTTPClientTransport(new URL(spec.url), {
199
- ...(spec.headers ? { requestInit: { headers: spec.headers } } : {}),
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
+ },
200
211
  });
201
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
+ }
202
244
  export async function openDownstream(spec) {
203
245
  if (!PREFIX_RE.test(spec.prefix)) {
204
246
  throw new Error("downstream-prefix-invalid");
@@ -214,12 +256,16 @@ export async function openDownstream(spec) {
214
256
  const client = new Client({ name: "verax-downstream", version: "0.0.0" });
215
257
  let listed;
216
258
  try {
217
- await client.connect(transport);
218
- listed = await client.listTools();
259
+ await connectWithDeadline(client, transport, timeoutMs, spec.prefix);
260
+ listed = await client.listTools(undefined, { timeout: timeoutMs });
219
261
  }
220
262
  catch (err) {
221
263
  await shut(client, transport);
222
- 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}`);
223
269
  }
224
270
  const tools = [];
225
271
  const seen = new Set();
@@ -228,6 +274,15 @@ export async function openDownstream(spec) {
228
274
  await shut(client, transport);
229
275
  throw new Error(`downstream-child-name-invalid:${tool.name}`);
230
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
+ }
231
286
  const name = prefixedName(spec.prefix, tool.name);
232
287
  if (seen.has(name)) {
233
288
  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.1.4",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {