@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 +1 -1
- package/dist/cli.js +6 -0
- package/dist/doctor.js +28 -0
- package/dist/downstream.d.ts +6 -0
- package/dist/downstream.js +83 -4
- package/dist/verify-cli.d.ts +5 -0
- package/dist/verify-cli.js +91 -0
- package/package.json +3 -3
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/"];
|
package/dist/downstream.d.ts
CHANGED
|
@@ -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;
|
package/dist/downstream.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
26
|
-
"@verax-ai/proxy": "^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.",
|