@verax-ai/body 0.1.4 → 0.2.1
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 +10 -0
- package/dist/demo.d.ts +11 -0
- package/dist/demo.js +356 -0
- package/dist/doctor.js +28 -0
- package/dist/downstream.d.ts +6 -0
- package/dist/downstream.js +24 -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
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { runApprove } from "./approve-cli.js";
|
|
6
|
+
import { runDemo } from "./demo.js";
|
|
6
7
|
import { desktopMain } from "./desktop.js";
|
|
7
8
|
import { doctorExit, runDoctor } from "./doctor.js";
|
|
8
9
|
import { runHalt } from "./halt.js";
|
|
@@ -18,6 +19,7 @@ Usage: verax <command> [options]
|
|
|
18
19
|
|
|
19
20
|
(no command) serve MCP over Streamable HTTP at /mcp on VERAX_BIND (default 127.0.0.1:8787)
|
|
20
21
|
doctor [--json] check the configuration this process would run with
|
|
22
|
+
demo [--keep] run a loopback body against a temporary ledger and print what it recorded
|
|
21
23
|
approve <args> approve a waiting request from this machine
|
|
22
24
|
operator <args> enrol an operator and manage their passkeys
|
|
23
25
|
reconcile <args> compare the ledger against a statement
|
|
@@ -62,6 +64,14 @@ if (argv[0] === "--version" || argv[0] === "-v") {
|
|
|
62
64
|
process.stdout.write(`${version()}\n`);
|
|
63
65
|
process.exit(0);
|
|
64
66
|
}
|
|
67
|
+
if (argv[0] === "demo") {
|
|
68
|
+
process.exit(await runDemo(argv, process.env, {
|
|
69
|
+
stdout: process.stdout,
|
|
70
|
+
stderr: process.stderr,
|
|
71
|
+
stdin: process.stdin,
|
|
72
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
65
75
|
if (argv[0] === "approve") {
|
|
66
76
|
process.exit(await runApprove(argv));
|
|
67
77
|
}
|
package/dist/demo.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DemoIo = {
|
|
2
|
+
stdout: {
|
|
3
|
+
write(s: string): unknown;
|
|
4
|
+
};
|
|
5
|
+
stderr: {
|
|
6
|
+
write(s: string): unknown;
|
|
7
|
+
};
|
|
8
|
+
stdin: NodeJS.ReadableStream;
|
|
9
|
+
isTTY: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function runDemo(argv: string[], env: NodeJS.ProcessEnv, io: DemoIo): Promise<number>;
|
package/dist/demo.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
|
+
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
10
|
+
import { verifyLedger } from "@verax-ai/proxy";
|
|
11
|
+
import { runApprove } from "./approve-cli.js";
|
|
12
|
+
import { listen } from "./server.js";
|
|
13
|
+
const NOT_SHOWN = "Not shown here: data masking arrives with a downstream server such as Conarium; statement reconciliation needs a real statement (verax reconcile).";
|
|
14
|
+
const AUDIENCE = "http://127.0.0.1/verax-demo";
|
|
15
|
+
const KID = "demo";
|
|
16
|
+
const TOKEN_TTL_SEC = 5 * 60;
|
|
17
|
+
const DENY_HOST = "blocked.test";
|
|
18
|
+
const EGRESS_HOST = "example.com";
|
|
19
|
+
const SPEND = {
|
|
20
|
+
amountMinor: 100,
|
|
21
|
+
currency: "USD",
|
|
22
|
+
payee: "sample-merchant",
|
|
23
|
+
reference: "demo-1",
|
|
24
|
+
};
|
|
25
|
+
function redact(text, token) {
|
|
26
|
+
if (token === "" || !text.includes(token))
|
|
27
|
+
return text;
|
|
28
|
+
return text.split(token).join("[token]");
|
|
29
|
+
}
|
|
30
|
+
function shippedPolicyPath() {
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
let resolved = null;
|
|
33
|
+
try {
|
|
34
|
+
resolved = join(dirname(fileURLToPath(import.meta.resolve("@verax-ai/proxy/package.json"))), "policy", "default.json");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
resolved = null;
|
|
38
|
+
}
|
|
39
|
+
const candidates = [
|
|
40
|
+
resolved,
|
|
41
|
+
join(here, "..", "..", "proxy", "policy", "default.json"),
|
|
42
|
+
join(here, "..", "..", "@verax-ai", "proxy", "policy", "default.json"),
|
|
43
|
+
];
|
|
44
|
+
for (const path of candidates) {
|
|
45
|
+
if (path && existsSync(path))
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
throw new Error("demo: shipped policy file not found");
|
|
49
|
+
}
|
|
50
|
+
/** Shipped default plus message.send egress and a spend rule, written under the temp dir. */
|
|
51
|
+
function writeDemoPolicy(stateDir) {
|
|
52
|
+
const shipped = JSON.parse(readFileSync(shippedPolicyPath(), "utf8"));
|
|
53
|
+
const document = {
|
|
54
|
+
...shipped,
|
|
55
|
+
egress: [EGRESS_HOST],
|
|
56
|
+
rules: [
|
|
57
|
+
...shipped.rules,
|
|
58
|
+
{
|
|
59
|
+
id: "message-send",
|
|
60
|
+
tool: "message.send",
|
|
61
|
+
requires: ["verax:memory"],
|
|
62
|
+
egress: true,
|
|
63
|
+
text: "Queueing a message needs the memory scope and a recipient host on the egress list.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "spend",
|
|
67
|
+
tool: "spend",
|
|
68
|
+
requires: ["verax:pay"],
|
|
69
|
+
mode: "approve",
|
|
70
|
+
text: "A payment is held for an operator.",
|
|
71
|
+
spend: {
|
|
72
|
+
maxAmountMinor: 5000,
|
|
73
|
+
currency: "USD",
|
|
74
|
+
payees: [SPEND.payee],
|
|
75
|
+
dailyMaxMinor: 10_000,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const path = join(stateDir, "policy.json");
|
|
81
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
function closeHttp(server) {
|
|
85
|
+
if (!server)
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
if (typeof server.closeAllConnections === "function")
|
|
89
|
+
server.closeAllConnections();
|
|
90
|
+
server.close(() => resolve());
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async function serveJwks(jwk) {
|
|
94
|
+
const server = createServer((req, res) => {
|
|
95
|
+
if (req.method === "GET" && (req.url === "/.well-known/jwks.json" || req.url === "/.well-known/jwks.json/")) {
|
|
96
|
+
const body = JSON.stringify({ keys: [jwk] });
|
|
97
|
+
res.writeHead(200, {
|
|
98
|
+
"content-type": "application/json",
|
|
99
|
+
"content-length": Buffer.byteLength(body),
|
|
100
|
+
});
|
|
101
|
+
res.end(body);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
res.writeHead(404);
|
|
105
|
+
res.end();
|
|
106
|
+
});
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
server.once("error", reject);
|
|
109
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
110
|
+
});
|
|
111
|
+
const addr = server.address();
|
|
112
|
+
if (!addr || typeof addr === "string") {
|
|
113
|
+
server.close();
|
|
114
|
+
throw new Error("demo: jwks bind failed");
|
|
115
|
+
}
|
|
116
|
+
return { server, issuer: `http://127.0.0.1:${addr.port}` };
|
|
117
|
+
}
|
|
118
|
+
async function mintToken(privateKey, issuer, audience) {
|
|
119
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
120
|
+
return new SignJWT({ scope: "verax:read verax:memory verax:pay" })
|
|
121
|
+
.setProtectedHeader({ alg: "ES256", kid: KID })
|
|
122
|
+
.setSubject("demo-brain")
|
|
123
|
+
.setIssuer(issuer)
|
|
124
|
+
.setAudience(audience)
|
|
125
|
+
.setIssuedAt(nowSec)
|
|
126
|
+
.setExpirationTime(nowSec + TOKEN_TTL_SEC)
|
|
127
|
+
.setJti(randomUUID())
|
|
128
|
+
.sign(privateKey);
|
|
129
|
+
}
|
|
130
|
+
function toolText(result) {
|
|
131
|
+
const content = result && typeof result === "object" ? result.content : undefined;
|
|
132
|
+
if (!Array.isArray(content))
|
|
133
|
+
return "";
|
|
134
|
+
return content
|
|
135
|
+
.map((part) => part && typeof part === "object" && "text" in part && typeof part.text === "string"
|
|
136
|
+
? part.text
|
|
137
|
+
: "")
|
|
138
|
+
.filter((s) => s !== "")
|
|
139
|
+
.join("\n");
|
|
140
|
+
}
|
|
141
|
+
function parseTagged(text, kind) {
|
|
142
|
+
const m = new RegExp(`^${kind}:([^:]+):(.+)$`).exec(text.trim());
|
|
143
|
+
return m ? { code: m[1], ref: m[2] } : null;
|
|
144
|
+
}
|
|
145
|
+
async function readAnswer(stdin) {
|
|
146
|
+
const ended = stdin.readableEnded;
|
|
147
|
+
if (ended)
|
|
148
|
+
return "";
|
|
149
|
+
return await new Promise((resolve) => {
|
|
150
|
+
const done = (value) => {
|
|
151
|
+
stdin.off("data", onData);
|
|
152
|
+
stdin.off("end", onEnd);
|
|
153
|
+
stdin.off("error", onErr);
|
|
154
|
+
resolve(value);
|
|
155
|
+
};
|
|
156
|
+
const onData = (chunk) => {
|
|
157
|
+
done(String(chunk).split(/\r?\n/)[0] ?? "");
|
|
158
|
+
};
|
|
159
|
+
const onEnd = () => done("");
|
|
160
|
+
const onErr = () => done("");
|
|
161
|
+
stdin.once("data", onData);
|
|
162
|
+
stdin.once("end", onEnd);
|
|
163
|
+
stdin.once("error", onErr);
|
|
164
|
+
if (typeof stdin.resume === "function")
|
|
165
|
+
stdin.resume();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export async function runDemo(argv, env, io) {
|
|
169
|
+
if (env.NODE_ENV === "production") {
|
|
170
|
+
io.stderr.write("demo refuses NODE_ENV=production\n");
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const keep = argv.includes("--keep");
|
|
174
|
+
let stateDir = null;
|
|
175
|
+
let jwks = null;
|
|
176
|
+
let body = null;
|
|
177
|
+
let client = null;
|
|
178
|
+
let token = "";
|
|
179
|
+
let cleaned = false;
|
|
180
|
+
const writeOut = (s) => {
|
|
181
|
+
io.stdout.write(redact(s, token));
|
|
182
|
+
};
|
|
183
|
+
const writeErr = (s) => {
|
|
184
|
+
io.stderr.write(redact(s, token));
|
|
185
|
+
};
|
|
186
|
+
const cleanup = async () => {
|
|
187
|
+
if (cleaned)
|
|
188
|
+
return;
|
|
189
|
+
cleaned = true;
|
|
190
|
+
process.off("SIGINT", onStop);
|
|
191
|
+
process.off("SIGTERM", onStop);
|
|
192
|
+
if (client)
|
|
193
|
+
await client.close().catch(() => undefined);
|
|
194
|
+
client = null;
|
|
195
|
+
await closeHttp(body);
|
|
196
|
+
body = null;
|
|
197
|
+
await closeHttp(jwks);
|
|
198
|
+
jwks = null;
|
|
199
|
+
if (stateDir && !keep) {
|
|
200
|
+
try {
|
|
201
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// OS temp cleanup
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const onStop = () => {
|
|
209
|
+
void cleanup();
|
|
210
|
+
};
|
|
211
|
+
process.once("SIGINT", onStop);
|
|
212
|
+
process.once("SIGTERM", onStop);
|
|
213
|
+
try {
|
|
214
|
+
stateDir = mkdtempSync(join(tmpdir(), "verax-demo-"));
|
|
215
|
+
const policyFile = writeDemoPolicy(stateDir);
|
|
216
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256");
|
|
217
|
+
const jwk = { ...(await exportJWK(publicKey)), alg: "ES256", use: "sig", kid: KID };
|
|
218
|
+
const jwksBind = await serveJwks(jwk);
|
|
219
|
+
jwks = jwksBind.server;
|
|
220
|
+
const issuer = jwksBind.issuer;
|
|
221
|
+
const jwksUrl = `${issuer}/.well-known/jwks.json`;
|
|
222
|
+
body = await listen({
|
|
223
|
+
issuer,
|
|
224
|
+
jwksUrl,
|
|
225
|
+
audience: AUDIENCE,
|
|
226
|
+
stateDir,
|
|
227
|
+
bindHost: "127.0.0.1",
|
|
228
|
+
bindPort: 0,
|
|
229
|
+
policyFile,
|
|
230
|
+
tlsTerminated: false,
|
|
231
|
+
});
|
|
232
|
+
const addr = body.address();
|
|
233
|
+
if (!addr || typeof addr === "string")
|
|
234
|
+
throw new Error("demo: body bind failed");
|
|
235
|
+
const mcpUrl = `http://127.0.0.1:${addr.port}/mcp`;
|
|
236
|
+
token = await mintToken(privateKey, issuer, AUDIENCE);
|
|
237
|
+
client = new Client({ name: "verax-demo", version: "0.0.0" });
|
|
238
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
|
239
|
+
requestInit: { headers: { authorization: `Bearer ${token}` } },
|
|
240
|
+
}));
|
|
241
|
+
writeOut("verax demo\n");
|
|
242
|
+
writeOut("\n");
|
|
243
|
+
const put = await client.callTool({
|
|
244
|
+
name: "memory.put",
|
|
245
|
+
arguments: {
|
|
246
|
+
id: "demo-note",
|
|
247
|
+
body: { text: "loopback demo" },
|
|
248
|
+
source: { kind: "demo" },
|
|
249
|
+
validUntilMs: Date.now() + 60 * 60 * 1000,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
if (put.isError)
|
|
253
|
+
throw new Error(`demo: memory.put ${toolText(put)}`);
|
|
254
|
+
const got = await client.callTool({ name: "memory.get", arguments: { id: "demo-note" } });
|
|
255
|
+
if (got.isError)
|
|
256
|
+
throw new Error(`demo: memory.get ${toolText(got)}`);
|
|
257
|
+
writeOut("memory.put / memory.get\n");
|
|
258
|
+
writeOut(" allowed; two signed records\n");
|
|
259
|
+
writeOut("\n");
|
|
260
|
+
const sent = await client.callTool({
|
|
261
|
+
name: "message.send",
|
|
262
|
+
arguments: { to: `ops@${DENY_HOST}`, text: "demo" },
|
|
263
|
+
});
|
|
264
|
+
const denied = parseTagged(toolText(sent), "denied");
|
|
265
|
+
if (!denied)
|
|
266
|
+
throw new Error(`demo: message.send ${toolText(sent)}`);
|
|
267
|
+
writeOut(`message.send -> ops@${DENY_HOST}\n`);
|
|
268
|
+
writeOut(` refused ${denied.code} (signed)\n`);
|
|
269
|
+
writeOut(` ref ${denied.ref}\n`);
|
|
270
|
+
writeOut("\n");
|
|
271
|
+
const spent = await client.callTool({
|
|
272
|
+
name: "spend",
|
|
273
|
+
arguments: { ...SPEND },
|
|
274
|
+
});
|
|
275
|
+
const held = parseTagged(toolText(spent), "deferred");
|
|
276
|
+
if (!held)
|
|
277
|
+
throw new Error(`demo: spend ${toolText(spent)}`);
|
|
278
|
+
writeOut(`spend ${SPEND.amountMinor} minor ${SPEND.currency} ${SPEND.payee}\n`);
|
|
279
|
+
writeOut(" held\n");
|
|
280
|
+
let approved = false;
|
|
281
|
+
if (io.isTTY) {
|
|
282
|
+
writeOut("Approve this payment as the operator on this machine? [y/N]\n");
|
|
283
|
+
const answer = await readAnswer(io.stdin);
|
|
284
|
+
if (answer.trim().toLowerCase() === "y") {
|
|
285
|
+
const code = await runApprove(["approve", stateDir, held.ref], (s) => writeErr(s), () => undefined);
|
|
286
|
+
if (code !== 0)
|
|
287
|
+
throw new Error("demo: approve failed");
|
|
288
|
+
approved = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (approved) {
|
|
292
|
+
writeOut(" approved; operator id bound on the record\n");
|
|
293
|
+
}
|
|
294
|
+
else if (io.isTTY) {
|
|
295
|
+
writeOut(" left held\n");
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
writeOut(" no terminal to ask, so it stays held (run this in a terminal to be asked)\n");
|
|
299
|
+
}
|
|
300
|
+
writeOut("\n");
|
|
301
|
+
const explained = await client.callTool({
|
|
302
|
+
name: "audit.explain",
|
|
303
|
+
arguments: { ref: denied.ref },
|
|
304
|
+
});
|
|
305
|
+
if (explained.isError)
|
|
306
|
+
throw new Error(`demo: audit.explain ${toolText(explained)}`);
|
|
307
|
+
let findingCode = "none";
|
|
308
|
+
let trust = "unread";
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(toolText(explained));
|
|
311
|
+
if (typeof parsed.finding?.code === "string")
|
|
312
|
+
findingCode = parsed.finding.code;
|
|
313
|
+
if (typeof parsed.trustRoot?.source === "string")
|
|
314
|
+
trust = parsed.trustRoot.source;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
findingCode = "unparsed";
|
|
318
|
+
}
|
|
319
|
+
writeOut("audit.explain of the refuse\n");
|
|
320
|
+
writeOut(` finding ${findingCode}\n`);
|
|
321
|
+
writeOut(` trust-root ${trust}\n`);
|
|
322
|
+
if (findingCode === "none" && trust !== "unread") {
|
|
323
|
+
writeOut(" chain and signatures read back\n");
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
writeOut(" the read-back did not come out clean; see the finding above\n");
|
|
327
|
+
}
|
|
328
|
+
writeOut("\n");
|
|
329
|
+
await client.close().catch(() => undefined);
|
|
330
|
+
client = null;
|
|
331
|
+
await closeHttp(body);
|
|
332
|
+
body = null;
|
|
333
|
+
await closeHttp(jwks);
|
|
334
|
+
jwks = null;
|
|
335
|
+
const counted = await verifyLedger(stateDir);
|
|
336
|
+
writeOut(`records ${counted.decisions}\n`);
|
|
337
|
+
writeOut(`effects ${counted.effects}\n`);
|
|
338
|
+
if (keep) {
|
|
339
|
+
writeOut(`ledger ${stateDir}\n`);
|
|
340
|
+
writeOut(`verax verify ${stateDir}\n`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
writeOut("ledger removed on exit (run with --keep to keep it and check it with verax verify)\n");
|
|
344
|
+
}
|
|
345
|
+
writeOut("\n");
|
|
346
|
+
writeOut(`${NOT_SHOWN}\n`);
|
|
347
|
+
await cleanup();
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const detail = err instanceof Error ? err.message : "fault";
|
|
352
|
+
writeErr(`demo: ${detail}\n`);
|
|
353
|
+
await cleanup();
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
}
|
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
|
@@ -29,6 +29,18 @@ export function prefixedName(prefix, childName) {
|
|
|
29
29
|
export function extraToolNameOk(name) {
|
|
30
30
|
return EXTRA_NAME_RE.test(name);
|
|
31
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
|
+
}
|
|
32
44
|
export function parseDownstreamJson(raw) {
|
|
33
45
|
let parsed;
|
|
34
46
|
try {
|
|
@@ -117,6 +129,16 @@ export function parseDownstreamJson(raw) {
|
|
|
117
129
|
}
|
|
118
130
|
spec.timeoutMs = rec.timeoutMs;
|
|
119
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);
|
|
120
142
|
return spec;
|
|
121
143
|
}
|
|
122
144
|
/**
|
|
@@ -175,6 +197,8 @@ function stdioTransport(spec) {
|
|
|
175
197
|
if (spec.command === undefined || spec.command.trim() === "") {
|
|
176
198
|
throw new Error("downstream-command-invalid");
|
|
177
199
|
}
|
|
200
|
+
// Last check before the spawn: openDownstream can be called without parse.
|
|
201
|
+
assertStdioTrusted(spec);
|
|
178
202
|
// Safe inherit + operator overlay. Not process.env: that would copy
|
|
179
203
|
// VERAX_* tokens the body already holds.
|
|
180
204
|
const env = { ...getDefaultEnvironment(), ...(spec.env ?? {}) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verax-ai/body",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
|
26
|
-
"@verax-ai/proxy": "^0.1
|
|
25
|
+
"@verax-ai/inventory": "^0.2.1",
|
|
26
|
+
"@verax-ai/proxy": "^0.2.1",
|
|
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.",
|