@verax-ai/body 0.2.1 → 0.3.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
@@ -29,7 +29,8 @@ Bearer token the configured issuer did not sign.
29
29
  | Variable | Required | Meaning |
30
30
  | --- | --- | --- |
31
31
  | `VERAX_ISSUER` | yes | Issuer the agent's token must carry. |
32
- | `VERAX_JWKS_URL` | yes | Where the body fetches the keys that verify that token. |
32
+ | `VERAX_JWKS_URL` | one of | Where the body fetches the keys that verify that token. Set this or `VERAX_JWKS_FILE`, not both. |
33
+ | `VERAX_JWKS_FILE` | one of | Path to a JWKS JSON file read once at start, instead of fetching `VERAX_JWKS_URL`. Accepted only with a loopback bind. |
33
34
  | `VERAX_AUDIENCE` | yes | Audience the token must name, so a token minted for something else is refused. |
34
35
  | `VERAX_STATE_DIR` | yes | Directory the ledger, keys and approvals live in. It stays on this machine. |
35
36
  | `VERAX_POLICY_FILE` | yes | Policy the gate applies. `@verax-ai/proxy` ships `policy/default.json`, which denies what it does not name. |
package/dist/auth.d.ts CHANGED
@@ -6,5 +6,9 @@ export type VerifiedBearer = {
6
6
  };
7
7
  export declare function resourceMetadataUrl(audience: string): string;
8
8
  export declare function wwwAuthenticate(audience: string): string;
9
- export declare function createVerifier(jwksUrl: string, issuer: string, audience: string): (token: string) => Promise<VerifiedBearer>;
9
+ export declare class JwksFileError extends Error {
10
+ readonly code = 78;
11
+ constructor(message?: string);
12
+ }
13
+ export declare function createVerifier(jwksUrl: string, issuer: string, audience: string, jwksFile?: string | null): (token: string) => Promise<VerifiedBearer>;
10
14
  export declare function readBearer(header: string | undefined): string | null;
package/dist/auth.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createRemoteJWKSet, jwtVerify } from "jose";
1
+ import { readFileSync } from "node:fs";
2
+ import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose";
2
3
  const ALGS = ["ES256", "EdDSA"];
3
4
  export function resourceMetadataUrl(audience) {
4
5
  const u = new URL(audience);
@@ -11,8 +12,31 @@ export function resourceMetadataUrl(audience) {
11
12
  export function wwwAuthenticate(audience) {
12
13
  return `Bearer resource_metadata="${resourceMetadataUrl(audience)}"`;
13
14
  }
14
- export function createVerifier(jwksUrl, issuer, audience) {
15
- const jwks = createRemoteJWKSet(new URL(jwksUrl));
15
+ function localJwks(path) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(readFileSync(path, "utf8"));
19
+ }
20
+ catch {
21
+ throw new JwksFileError();
22
+ }
23
+ if (parsed === null ||
24
+ typeof parsed !== "object" ||
25
+ !Array.isArray(parsed.keys) ||
26
+ parsed.keys.length === 0) {
27
+ throw new JwksFileError();
28
+ }
29
+ return createLocalJWKSet(parsed);
30
+ }
31
+ export class JwksFileError extends Error {
32
+ code = 78;
33
+ constructor(message = "VERAX_JWKS_FILE is missing or unparsable") {
34
+ super(message);
35
+ this.name = "JwksFileError";
36
+ }
37
+ }
38
+ export function createVerifier(jwksUrl, issuer, audience, jwksFile) {
39
+ const jwks = jwksFile ? localJwks(jwksFile) : createRemoteJWKSet(new URL(jwksUrl));
16
40
  return async (token) => {
17
41
  const { payload } = await jwtVerify(token, jwks, {
18
42
  issuer,
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { runApprove } from "./approve-cli.js";
6
6
  import { runDemo } from "./demo.js";
7
7
  import { desktopMain } from "./desktop.js";
8
8
  import { doctorExit, runDoctor } from "./doctor.js";
9
+ import { loadEnvFile, runInitLocal } from "./init-local.js";
9
10
  import { runHalt } from "./halt.js";
10
11
  import { main } from "./main.js";
11
12
  import { runOperator } from "./operator-cli.js";
@@ -18,8 +19,12 @@ const HELP = `verax - the body an agent asks before it acts, and the ledger it a
18
19
  Usage: verax <command> [options]
19
20
 
20
21
  (no command) serve MCP over Streamable HTTP at /mcp on VERAX_BIND (default 127.0.0.1:8787)
22
+ serve [--env-file <file>] same as serving, after loading KEY=VALUE lines from that file
23
+ init --local <stateDir> [--force] [--days N] [--port N]
24
+ write a loopback key, one agent token, and verax.env
21
25
  doctor [--json] check the configuration this process would run with
22
26
  demo [--keep] run a loopback body against a temporary ledger and print what it recorded
27
+ [--with-conarium] also fetch Conarium with npx, attach it as a child, and put a masked read through the gate
23
28
  approve <args> approve a waiting request from this machine
24
29
  operator <args> enrol an operator and manage their passkeys
25
30
  reconcile <args> compare the ledger against a statement
@@ -33,9 +38,9 @@ Usage: verax <command> [options]
33
38
  --help, -h print this
34
39
  --version, -v print the version
35
40
 
36
- Serving needs VERAX_ISSUER, VERAX_JWKS_URL, VERAX_AUDIENCE, VERAX_STATE_DIR and
37
- VERAX_POLICY_FILE; \`verax doctor\` names what is missing. Records stay on this
38
- machine, under VERAX_STATE_DIR.
41
+ Serving needs VERAX_ISSUER, one of VERAX_JWKS_URL or VERAX_JWKS_FILE, VERAX_AUDIENCE,
42
+ VERAX_STATE_DIR and VERAX_POLICY_FILE; \`verax doctor\` names what is missing.
43
+ Records stay on this machine, under VERAX_STATE_DIR.
39
44
  `;
40
45
  function version() {
41
46
  // The published package answers with its own version, not a copy of it.
@@ -95,6 +100,28 @@ if (argv[0] === "unlock") {
95
100
  }
96
101
  process.exit(runUnlock(stateDir, (s) => process.stderr.write(s), { force }));
97
102
  }
103
+ if (argv[0] === "init") {
104
+ process.exit(await runInitLocal(argv.slice(1)));
105
+ }
106
+ if (argv[0] === "serve") {
107
+ const fileFlag = argv.indexOf("--env-file");
108
+ if (fileFlag !== -1) {
109
+ const file = argv[fileFlag + 1];
110
+ if (!file || file.startsWith("-")) {
111
+ process.stderr.write("verax serve --env-file <file>\n");
112
+ process.exit(78);
113
+ }
114
+ const loaded = loadEnvFile(file);
115
+ if (!loaded.ok) {
116
+ process.stderr.write(`${loaded.reason}\n`);
117
+ process.exit(78);
118
+ }
119
+ }
120
+ await main();
121
+ // main() returns once the server is listening. The open socket keeps the
122
+ // process up; this promise stops the rest of the dispatcher from running.
123
+ await new Promise(() => { });
124
+ }
98
125
  if (argv[0] === "doctor") {
99
126
  const json = argv.includes("--json");
100
127
  const checks = runDoctor(process.env, process.argv);
package/dist/config.d.ts CHANGED
@@ -8,6 +8,11 @@ export type BodyConfig = {
8
8
  bindPort: number;
9
9
  policyFile: string;
10
10
  tlsTerminated: boolean;
11
+ /**
12
+ * Path to a JWKS file read once at start. Set instead of `jwksUrl`.
13
+ * Loopback binds only.
14
+ */
15
+ jwksFile?: string | null;
11
16
  /** Path to an inventory JSON file. Missing file is absence, not a fault. */
12
17
  inventoryFile?: string | null;
13
18
  /**
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  export const EX_CONFIG = 78;
2
3
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
3
4
  function parseBind(raw) {
@@ -14,11 +15,28 @@ export function isLoopbackHost(host) {
14
15
  const stripped = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
15
16
  return LOOPBACK.has(stripped);
16
17
  }
18
+ function jwksFileParses(path) {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
21
+ return Array.isArray(parsed.keys) && parsed.keys.length > 0;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
17
27
  export function loadConfig(env) {
18
28
  const issuer = env.VERAX_ISSUER?.trim() ?? "";
19
29
  const jwksUrl = env.VERAX_JWKS_URL?.trim() ?? "";
30
+ const jwksFile = env.VERAX_JWKS_FILE?.trim() ?? "";
20
31
  const audience = env.VERAX_AUDIENCE?.trim() ?? "";
21
- if (issuer === "" || jwksUrl === "" || audience === "") {
32
+ if (jwksUrl !== "" && jwksFile !== "") {
33
+ return {
34
+ ok: false,
35
+ code: EX_CONFIG,
36
+ reason: "both VERAX_JWKS_URL and VERAX_JWKS_FILE",
37
+ };
38
+ }
39
+ if (issuer === "" || audience === "" || (jwksUrl === "" && jwksFile === "")) {
22
40
  return {
23
41
  ok: false,
24
42
  code: EX_CONFIG,
@@ -27,6 +45,13 @@ export function loadConfig(env) {
27
45
  }
28
46
  const bind = parseBind(env.VERAX_BIND);
29
47
  const tlsTerminated = env.VERAX_TLS_TERMINATED === "1";
48
+ if (jwksFile !== "" && !isLoopbackHost(bind.host)) {
49
+ return {
50
+ ok: false,
51
+ code: EX_CONFIG,
52
+ reason: "VERAX_JWKS_FILE needs a loopback bind",
53
+ };
54
+ }
30
55
  if (!isLoopbackHost(bind.host) && !tlsTerminated) {
31
56
  return {
32
57
  ok: false,
@@ -34,6 +59,13 @@ export function loadConfig(env) {
34
59
  reason: "non-loopback bind without VERAX_TLS_TERMINATED=1",
35
60
  };
36
61
  }
62
+ if (jwksFile !== "" && !jwksFileParses(jwksFile)) {
63
+ return {
64
+ ok: false,
65
+ code: EX_CONFIG,
66
+ reason: "VERAX_JWKS_FILE is missing or unparsable",
67
+ };
68
+ }
37
69
  const stateDir = env.VERAX_STATE_DIR?.trim() ?? "";
38
70
  if (stateDir === "") {
39
71
  return { ok: false, code: EX_CONFIG, reason: "missing VERAX_STATE_DIR" };
@@ -49,6 +81,7 @@ export function loadConfig(env) {
49
81
  value: {
50
82
  issuer,
51
83
  jwksUrl,
84
+ jwksFile: jwksFile === "" ? null : jwksFile,
52
85
  audience,
53
86
  stateDir,
54
87
  bindHost: bind.host,
package/dist/demo.d.ts CHANGED
@@ -8,4 +8,11 @@ export type DemoIo = {
8
8
  stdin: NodeJS.ReadableStream;
9
9
  isTTY: boolean;
10
10
  };
11
- export declare function runDemo(argv: string[], env: NodeJS.ProcessEnv, io: DemoIo): Promise<number>;
11
+ /** Test injection. The CLI never passes this; it is not read from argv or env. */
12
+ export type DemoOpts = {
13
+ conariumChild?: {
14
+ command: string;
15
+ args: string[];
16
+ };
17
+ };
18
+ export declare function runDemo(argv: string[], env: NodeJS.ProcessEnv, io: DemoIo, opts?: DemoOpts): Promise<number>;
package/dist/demo.js CHANGED
@@ -11,6 +11,16 @@ import { verifyLedger } from "@verax-ai/proxy";
11
11
  import { runApprove } from "./approve-cli.js";
12
12
  import { listen } from "./server.js";
13
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 NOT_SHOWN_WITH_CONARIUM = "Not shown here: a real database (these are Conarium's sample rows); statement reconciliation needs a real statement (verax reconcile).";
15
+ const NPX_DOWNLOAD_LINE = "npx will download @conarium-ai/core from npm and run it as a child process; a cold download takes longer.\n";
16
+ const CONARIUM_NPX = {
17
+ command: "npx",
18
+ args: ["-y", "--package=@conarium-ai/core@^0.2.51", "conarium", "--demo"],
19
+ };
20
+ const CONARIUM_TIMEOUT_MS = 120_000;
21
+ const CUSTOMERS_SQL = "SELECT name, email, card FROM public.customers";
22
+ const SECRETS_SQL = "SELECT * FROM public.secrets";
23
+ const MASKED_PII = "[MASKED_PII]";
14
24
  const AUDIENCE = "http://127.0.0.1/verax-demo";
15
25
  const KID = "demo";
16
26
  const TOKEN_TTL_SEC = 5 * 60;
@@ -48,7 +58,7 @@ function shippedPolicyPath() {
48
58
  throw new Error("demo: shipped policy file not found");
49
59
  }
50
60
  /** Shipped default plus message.send egress and a spend rule, written under the temp dir. */
51
- function writeDemoPolicy(stateDir) {
61
+ function writeDemoPolicy(stateDir, withConarium) {
52
62
  const shipped = JSON.parse(readFileSync(shippedPolicyPath(), "utf8"));
53
63
  const document = {
54
64
  ...shipped,
@@ -75,12 +85,34 @@ function writeDemoPolicy(stateDir) {
75
85
  dailyMaxMinor: 10_000,
76
86
  },
77
87
  },
88
+ ...(withConarium
89
+ ? [
90
+ {
91
+ id: "conarium-query",
92
+ tool: "conarium.query",
93
+ requires: ["verax:read"],
94
+ text: "A data read through Conarium needs the read scope.",
95
+ },
96
+ ]
97
+ : []),
78
98
  ],
79
99
  };
80
100
  const path = join(stateDir, "policy.json");
81
101
  writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
82
102
  return path;
83
103
  }
104
+ function writeConariumDownstream(stateDir, child) {
105
+ const path = join(stateDir, "downstream.json");
106
+ const document = {
107
+ prefix: "conarium",
108
+ command: child.command,
109
+ args: [...child.args],
110
+ trust: "same-user",
111
+ timeoutMs: CONARIUM_TIMEOUT_MS,
112
+ };
113
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
114
+ return path;
115
+ }
84
116
  function closeHttp(server) {
85
117
  if (!server)
86
118
  return Promise.resolve();
@@ -142,6 +174,49 @@ function parseTagged(text, kind) {
142
174
  const m = new RegExp(`^${kind}:([^:]+):(.+)$`).exec(text.trim());
143
175
  return m ? { code: m[1], ref: m[2] } : null;
144
176
  }
177
+ async function callToolCaught(client, name, args) {
178
+ try {
179
+ const result = await client.callTool({ name, arguments: args });
180
+ return { isError: result.isError === true, text: toolText(result) };
181
+ }
182
+ catch (err) {
183
+ return { isError: true, text: err instanceof Error ? err.message : "fault" };
184
+ }
185
+ }
186
+ function customerRowsMasked(text) {
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(text);
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ if (parsed === null || typeof parsed !== "object")
195
+ return false;
196
+ const rows = parsed.rows;
197
+ if (!Array.isArray(rows) || rows.length === 0)
198
+ return false;
199
+ for (const row of rows) {
200
+ if (row === null || typeof row !== "object")
201
+ return false;
202
+ const rec = row;
203
+ if (rec.email !== MASKED_PII || rec.card !== MASKED_PII)
204
+ return false;
205
+ }
206
+ return true;
207
+ }
208
+ /**
209
+ * The body passes on that a downstream tool answered with an error, not what it
210
+ * said: the detail is the fixed word `call-failed`. A child that died or timed
211
+ * out carries the transport's own message there instead, and that is not an
212
+ * answer from Conarium.
213
+ */
214
+ function childAnsweredWithError(text, tool) {
215
+ return text.trim().endsWith(`downstream-call-failed:${tool}:call-failed`);
216
+ }
217
+ function attachFailedDetail(detail) {
218
+ return /downstream-attach-failed|downstream-attach-timeout/.test(detail);
219
+ }
145
220
  async function readAnswer(stdin) {
146
221
  const ended = stdin.readableEnded;
147
222
  if (ended)
@@ -165,18 +240,21 @@ async function readAnswer(stdin) {
165
240
  stdin.resume();
166
241
  });
167
242
  }
168
- export async function runDemo(argv, env, io) {
243
+ export async function runDemo(argv, env, io, opts) {
169
244
  if (env.NODE_ENV === "production") {
170
245
  io.stderr.write("demo refuses NODE_ENV=production\n");
171
246
  return 1;
172
247
  }
173
248
  const keep = argv.includes("--keep");
249
+ const withConarium = argv.includes("--with-conarium");
174
250
  let stateDir = null;
175
251
  let jwks = null;
176
252
  let body = null;
177
253
  let client = null;
178
254
  let token = "";
179
255
  let cleaned = false;
256
+ let discardState = false;
257
+ let failed = false;
180
258
  const writeOut = (s) => {
181
259
  io.stdout.write(redact(s, token));
182
260
  };
@@ -196,7 +274,7 @@ export async function runDemo(argv, env, io) {
196
274
  body = null;
197
275
  await closeHttp(jwks);
198
276
  jwks = null;
199
- if (stateDir && !keep) {
277
+ if (stateDir && (!keep || discardState)) {
200
278
  try {
201
279
  rmSync(stateDir, { recursive: true, force: true });
202
280
  }
@@ -212,13 +290,21 @@ export async function runDemo(argv, env, io) {
212
290
  process.once("SIGTERM", onStop);
213
291
  try {
214
292
  stateDir = mkdtempSync(join(tmpdir(), "verax-demo-"));
215
- const policyFile = writeDemoPolicy(stateDir);
293
+ const policyFile = writeDemoPolicy(stateDir, withConarium);
216
294
  const { privateKey, publicKey } = await generateKeyPair("ES256");
217
295
  const jwk = { ...(await exportJWK(publicKey)), alg: "ES256", use: "sig", kid: KID };
218
296
  const jwksBind = await serveJwks(jwk);
219
297
  jwks = jwksBind.server;
220
298
  const issuer = jwksBind.issuer;
221
299
  const jwksUrl = `${issuer}/.well-known/jwks.json`;
300
+ let downstreamFile;
301
+ if (withConarium) {
302
+ const child = opts?.conariumChild ?? CONARIUM_NPX;
303
+ downstreamFile = writeConariumDownstream(stateDir, child);
304
+ if (!opts?.conariumChild) {
305
+ writeErr(NPX_DOWNLOAD_LINE);
306
+ }
307
+ }
222
308
  body = await listen({
223
309
  issuer,
224
310
  jwksUrl,
@@ -228,6 +314,7 @@ export async function runDemo(argv, env, io) {
228
314
  bindPort: 0,
229
315
  policyFile,
230
316
  tlsTerminated: false,
317
+ ...(downstreamFile !== undefined ? { downstreamFile } : {}),
231
318
  });
232
319
  const addr = body.address();
233
320
  if (!addr || typeof addr === "string")
@@ -298,6 +385,49 @@ export async function runDemo(argv, env, io) {
298
385
  writeOut(" no terminal to ask, so it stays held (run this in a terminal to be asked)\n");
299
386
  }
300
387
  writeOut("\n");
388
+ if (withConarium) {
389
+ const customers = await callToolCaught(client, "conarium.query", { sql: CUSTOMERS_SQL });
390
+ if (customers.isError)
391
+ throw new Error(`demo: conarium.query customers ${customers.text}`);
392
+ writeOut("conarium.query customers\n");
393
+ if (customerRowsMasked(customers.text)) {
394
+ writeOut(" allowed; masked by Conarium before the rows left it\n");
395
+ writeOut(" measured [MASKED_PII] on every email and card\n");
396
+ }
397
+ else {
398
+ writeOut(" allowed; did not come out masked\n");
399
+ failed = true;
400
+ }
401
+ writeOut("\n");
402
+ const secrets = await callToolCaught(client, "conarium.query", { sql: SECRETS_SQL });
403
+ const secretsDenied = parseTagged(secrets.text, "denied");
404
+ writeOut("conarium.query public.secrets\n");
405
+ if (secretsDenied) {
406
+ writeOut(` refused ${secretsDenied.code} (signed)\n`);
407
+ failed = true;
408
+ }
409
+ else if (childAnsweredWithError(secrets.text, "conarium.query")) {
410
+ writeOut(" allowed by this gate; Conarium answered with an error and no rows\n");
411
+ writeOut(" recorded as a failed call\n");
412
+ }
413
+ else if (secrets.isError) {
414
+ writeOut(" allowed by this gate; the call to Conarium failed before it answered\n");
415
+ failed = true;
416
+ }
417
+ else {
418
+ writeOut(" allowed; Conarium did not refuse public.secrets\n");
419
+ failed = true;
420
+ }
421
+ writeOut("\n");
422
+ const listed = await callToolCaught(client, "conarium.list_tables", {});
423
+ const listedDenied = parseTagged(listed.text, "denied");
424
+ writeOut("conarium.list_tables\n");
425
+ if (!listedDenied)
426
+ throw new Error(`demo: conarium.list_tables ${listed.text}`);
427
+ writeOut(` refused ${listedDenied.code} (signed)\n`);
428
+ writeOut(" refused by this gate; the downstream server never saw the call\n");
429
+ writeOut("\n");
430
+ }
301
431
  const explained = await client.callTool({
302
432
  name: "audit.explain",
303
433
  arguments: { ref: denied.ref },
@@ -343,13 +473,19 @@ export async function runDemo(argv, env, io) {
343
473
  writeOut("ledger removed on exit (run with --keep to keep it and check it with verax verify)\n");
344
474
  }
345
475
  writeOut("\n");
346
- writeOut(`${NOT_SHOWN}\n`);
476
+ writeOut(`${withConarium ? NOT_SHOWN_WITH_CONARIUM : NOT_SHOWN}\n`);
347
477
  await cleanup();
348
- return 0;
478
+ return failed ? 1 : 0;
349
479
  }
350
480
  catch (err) {
351
481
  const detail = err instanceof Error ? err.message : "fault";
352
- writeErr(`demo: ${detail}\n`);
482
+ if (withConarium && attachFailedDetail(detail)) {
483
+ discardState = true;
484
+ writeErr(`could not start the Conarium child (${detail}). run without --with-conarium\n`);
485
+ }
486
+ else {
487
+ writeErr(`demo: ${detail}\n`);
488
+ }
353
489
  await cleanup();
354
490
  return 1;
355
491
  }
package/dist/desktop.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export declare function resolveRepoRoot(): string;
2
+ export declare const DESKTOP_CLONE_ONLY = "verax desktop runs from a clone of github.com/verax-ai/verax; it is not in the npm package\n";
3
+ export declare function desktopCloneError(root: string): string | null;
1
4
  export type DesktopOpts = {
2
5
  stateDir: string;
3
6
  panelPort: number;
package/dist/desktop.js CHANGED
@@ -6,7 +6,16 @@ import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { pidAlive, readLockFile } from "./unlock.js";
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(here, "..", "..", "..");
9
+ export function resolveRepoRoot() {
10
+ return join(here, "..", "..", "..");
11
+ }
12
+ export const DESKTOP_CLONE_ONLY = "verax desktop runs from a clone of github.com/verax-ai/verax; it is not in the npm package\n";
13
+ export function desktopCloneError(root) {
14
+ if (!existsSync(join(root, "scripts", "dev-issuer.mjs")) || !existsSync(join(root, "apps", "panel"))) {
15
+ return DESKTOP_CLONE_ONLY;
16
+ }
17
+ return null;
18
+ }
10
19
  /** Newest modification time under a file or directory, ignoring build output. */
11
20
  function newestUnder(path, since) {
12
21
  let entry;
@@ -228,6 +237,12 @@ function collectOutput(child, sink) {
228
237
  child.stderr?.on("data", feed);
229
238
  }
230
239
  export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)) {
240
+ const repoRoot = resolveRepoRoot();
241
+ const cloneOnly = desktopCloneError(repoRoot);
242
+ if (cloneOnly) {
243
+ writeErr(cloneOnly);
244
+ return 78;
245
+ }
231
246
  mkdirSync(opts.stateDir, { recursive: true });
232
247
  const tokenPath = join(opts.stateDir, "dev-token");
233
248
  const issuerScript = join(repoRoot, "scripts", "dev-issuer.mjs");
package/dist/doctor.js CHANGED
@@ -9,13 +9,18 @@ const SECRET_NAME = /^(VERAX_DEV_TOKEN|.*_(TOKEN|SECRET|KEY))$/;
9
9
  export function runDoctor(env, argv) {
10
10
  const checks = [];
11
11
  const issuer = Boolean(env.VERAX_ISSUER?.trim());
12
- const jwks = Boolean(env.VERAX_JWKS_URL?.trim());
12
+ const jwksUrl = Boolean(env.VERAX_JWKS_URL?.trim());
13
+ const jwksFileSet = Boolean(env.VERAX_JWKS_FILE?.trim());
14
+ const jwks = jwksUrl || jwksFileSet;
13
15
  const audience = Boolean(env.VERAX_AUDIENCE?.trim());
16
+ const tripleReady = issuer && jwks && audience;
14
17
  checks.push({
15
18
  id: "issuer-jwks-audience",
16
- level: issuer && jwks && audience ? "ok" : "fail",
17
- detail: issuer && jwks && audience
18
- ? "VERAX_ISSUER, VERAX_JWKS_URL, and VERAX_AUDIENCE are set"
19
+ level: tripleReady ? "ok" : "fail",
20
+ detail: tripleReady
21
+ ? jwksFileSet && !jwksUrl
22
+ ? "VERAX_ISSUER, VERAX_JWKS_FILE, and VERAX_AUDIENCE are set"
23
+ : "VERAX_ISSUER, VERAX_JWKS_URL, and VERAX_AUDIENCE are set"
19
24
  : "one of VERAX_ISSUER, VERAX_JWKS_URL, VERAX_AUDIENCE is missing",
20
25
  });
21
26
  const loaded = loadConfig({
@@ -43,7 +48,7 @@ export function runDoctor(env, argv) {
43
48
  });
44
49
  }
45
50
  }
46
- else if (loaded.reason.includes("non-loopback")) {
51
+ else if (loaded.reason.includes("non-loopback") || loaded.reason.includes("loopback bind")) {
47
52
  checks.push({ id: "bind", level: "fail", detail: loaded.reason });
48
53
  }
49
54
  else {
@@ -212,8 +217,61 @@ export function runDoctor(env, argv) {
212
217
  });
213
218
  }
214
219
  }
220
+ checks.push(...jwksFileChecks(env, stateDir));
215
221
  return checks;
216
222
  }
223
+ function jwksFileChecks(env, stateDir) {
224
+ const file = env.VERAX_JWKS_FILE?.trim() ?? "";
225
+ if (file === "")
226
+ return [];
227
+ const out = [];
228
+ let kid = "";
229
+ let parses = false;
230
+ try {
231
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
232
+ const first = parsed.keys?.[0];
233
+ if (first && typeof first.kid === "string" && first.kid !== "") {
234
+ parses = true;
235
+ kid = first.kid;
236
+ }
237
+ }
238
+ catch {
239
+ parses = false;
240
+ }
241
+ out.push({
242
+ id: "jwks-file",
243
+ level: parses ? "ok" : "fail",
244
+ detail: parses ? `VERAX_JWKS_FILE ${file} parses kid ${kid}` : `VERAX_JWKS_FILE ${file} does not parse`,
245
+ });
246
+ if (stateDir === "")
247
+ return out;
248
+ const tokenPath = join(stateDir, "local-issuer", "agent.token");
249
+ if (!existsSync(tokenPath))
250
+ return out;
251
+ const exp = expFromToken(readFileSync(tokenPath, "utf8"));
252
+ const weekSec = 7 * 24 * 60 * 60;
253
+ if (exp !== null && exp <= Math.floor(Date.now() / 1000) + weekSec) {
254
+ out.push({
255
+ id: "local-agent-token",
256
+ level: "warn",
257
+ detail: `${tokenPath} expires within 7 days`,
258
+ });
259
+ }
260
+ return out;
261
+ }
262
+ function expFromToken(raw) {
263
+ const parts = raw.trim().split(".");
264
+ if (parts.length < 2)
265
+ return null;
266
+ try {
267
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
268
+ const payload = JSON.parse(json);
269
+ return typeof payload.exp === "number" ? payload.exp : null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
217
275
  const DEFAULT_REDIRECTS = ["http://127.0.0.1:5173/", "http://127.0.0.1:4173/"];
218
276
  function stripSlash(s) {
219
277
  return s.replace(/\/$/, "");
@@ -0,0 +1,15 @@
1
+ export type InitIo = {
2
+ stdout: {
3
+ write(s: string): unknown;
4
+ };
5
+ stderr: {
6
+ write(s: string): unknown;
7
+ };
8
+ };
9
+ export declare function loadEnvFile(path: string, env?: NodeJS.ProcessEnv): {
10
+ ok: true;
11
+ } | {
12
+ ok: false;
13
+ reason: string;
14
+ };
15
+ export declare function runInitLocal(argv: readonly string[], io?: InitIo): Promise<number>;
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { calculateJwkThumbprint, exportJWK, exportPKCS8, generateKeyPair, importPKCS8, SignJWT, } from "jose";
6
+ import { EX_CONFIG } from "./config.js";
7
+ const ISSUER = "verax-local";
8
+ const SUBJECT = "local-agent";
9
+ const SCOPE = "verax:read verax:memory";
10
+ const DEFAULT_DAYS = 30;
11
+ const MAX_DAYS = 90;
12
+ const DEFAULT_PORT = 8787;
13
+ const MIN_PORT = 1024;
14
+ const MAX_PORT = 65535;
15
+ export function loadEnvFile(path, env = process.env) {
16
+ let text;
17
+ try {
18
+ text = readFileSync(path, "utf8");
19
+ }
20
+ catch {
21
+ return { ok: false, reason: "env file is missing or unreadable" };
22
+ }
23
+ for (const line of text.split(/\r?\n/)) {
24
+ const trimmed = line.trim();
25
+ if (trimmed === "" || trimmed.startsWith("#"))
26
+ continue;
27
+ const eq = trimmed.indexOf("=");
28
+ if (eq <= 0)
29
+ continue;
30
+ const key = trimmed.slice(0, eq).trim();
31
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
32
+ continue;
33
+ let value = trimmed.slice(eq + 1).trim();
34
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
35
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ env[key] = value;
39
+ }
40
+ return { ok: true };
41
+ }
42
+ function shippedPolicyPath() {
43
+ const here = dirname(fileURLToPath(import.meta.url));
44
+ let resolved = null;
45
+ try {
46
+ resolved = join(dirname(fileURLToPath(import.meta.resolve("@verax-ai/proxy/package.json"))), "policy", "default.json");
47
+ }
48
+ catch {
49
+ resolved = null;
50
+ }
51
+ const candidates = [
52
+ resolved,
53
+ join(here, "..", "..", "proxy", "policy", "default.json"),
54
+ join(here, "..", "..", "@verax-ai", "proxy", "policy", "default.json"),
55
+ ];
56
+ for (const path of candidates) {
57
+ if (path && existsSync(path))
58
+ return path;
59
+ }
60
+ return null;
61
+ }
62
+ function ownerOnly(path) {
63
+ try {
64
+ chmodSync(path, 0o600);
65
+ }
66
+ catch {
67
+ // The platform does not honour the mode bit.
68
+ }
69
+ }
70
+ function slashPath(path) {
71
+ return path.replaceAll("\\", "/");
72
+ }
73
+ function shQuote(path) {
74
+ return `'${slashPath(path).replaceAll("'", "'\\''")}'`;
75
+ }
76
+ function psQuote(path) {
77
+ return `'${slashPath(path).replaceAll("'", "''")}'`;
78
+ }
79
+ function parseInitArgs(argv) {
80
+ let stateDir = "";
81
+ let force = false;
82
+ let days = DEFAULT_DAYS;
83
+ let port = DEFAULT_PORT;
84
+ let sawDays = false;
85
+ let sawPort = false;
86
+ for (let i = 0; i < argv.length; i += 1) {
87
+ const a = argv[i];
88
+ if (a === "--local") {
89
+ stateDir = argv[i + 1] ?? "";
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (a === "--force") {
94
+ force = true;
95
+ continue;
96
+ }
97
+ if (a === "--days") {
98
+ sawDays = true;
99
+ const raw = argv[i + 1] ?? "";
100
+ i += 1;
101
+ if (!/^[0-9]+$/.test(raw))
102
+ return { error: "--days wants an integer from 1 to 90" };
103
+ days = Number(raw);
104
+ continue;
105
+ }
106
+ if (a === "--port") {
107
+ sawPort = true;
108
+ const raw = argv[i + 1] ?? "";
109
+ i += 1;
110
+ if (!/^[0-9]+$/.test(raw))
111
+ return { error: "--port wants an integer from 1024 to 65535" };
112
+ port = Number(raw);
113
+ continue;
114
+ }
115
+ return { error: `flag-unknown:${a}` };
116
+ }
117
+ if (stateDir === "" || stateDir.startsWith("-")) {
118
+ return { error: "verax init --local <stateDir> [--force] [--days N] [--port N]" };
119
+ }
120
+ if (sawDays && (!Number.isInteger(days) || days < 1 || days > MAX_DAYS)) {
121
+ return { error: "--days wants an integer from 1 to 90" };
122
+ }
123
+ if (sawPort && (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT)) {
124
+ return { error: "--port wants an integer from 1024 to 65535" };
125
+ }
126
+ return { stateDir: resolve(stateDir), force, days, port };
127
+ }
128
+ export async function runInitLocal(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
129
+ const parsed = parseInitArgs(argv);
130
+ if ("error" in parsed) {
131
+ io.stderr.write(`${parsed.error}\n`);
132
+ return EX_CONFIG;
133
+ }
134
+ const stateDir = parsed.stateDir;
135
+ const issuerDir = join(stateDir, "local-issuer");
136
+ if (existsSync(issuerDir) && !parsed.force) {
137
+ io.stderr.write(`refusing: ${issuerDir} already exists\n`);
138
+ return EX_CONFIG;
139
+ }
140
+ const policyDest = join(stateDir, "policy.json");
141
+ const policySrc = existsSync(policyDest) ? null : shippedPolicyPath();
142
+ if (!existsSync(policyDest) && !policySrc) {
143
+ io.stderr.write("shipped policy file not found\n");
144
+ return EX_CONFIG;
145
+ }
146
+ const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true });
147
+ const pem = await exportPKCS8(privateKey);
148
+ const jwk = await exportJWK(publicKey);
149
+ const thumb = await calculateJwkThumbprint(jwk, "sha256");
150
+ const hex = Buffer.from(thumb, "base64url").toString("hex").slice(0, 8);
151
+ const kid = `verax-local-${hex}`;
152
+ const publicJwk = { ...jwk, alg: "ES256", use: "sig", kid };
153
+ const key = await importPKCS8(pem, "ES256");
154
+ const now = Math.floor(Date.now() / 1000);
155
+ const origin = `http://127.0.0.1:${parsed.port}`;
156
+ const token = await new SignJWT({ scope: SCOPE })
157
+ .setProtectedHeader({ alg: "ES256", kid })
158
+ .setIssuer(ISSUER)
159
+ .setAudience(origin)
160
+ .setSubject(SUBJECT)
161
+ .setJti(randomUUID())
162
+ .setIssuedAt(now)
163
+ .setExpirationTime(now + parsed.days * 24 * 60 * 60)
164
+ .sign(key);
165
+ mkdirSync(issuerDir, { recursive: true, mode: 0o700 });
166
+ const keyPath = join(issuerDir, "key.pem");
167
+ const jwksPath = join(issuerDir, "jwks.json");
168
+ const tokenPath = join(issuerDir, "agent.token");
169
+ const envPath = join(stateDir, "verax.env");
170
+ writeFileSync(keyPath, pem, { encoding: "utf8", mode: 0o600 });
171
+ ownerOnly(keyPath);
172
+ writeFileSync(jwksPath, `${JSON.stringify({ keys: [publicJwk] })}\n`, { encoding: "utf8", mode: 0o600 });
173
+ writeFileSync(tokenPath, token, { encoding: "utf8", mode: 0o600 });
174
+ ownerOnly(tokenPath);
175
+ const wrote = [keyPath, jwksPath, tokenPath];
176
+ if (!existsSync(policyDest) && policySrc) {
177
+ writeFileSync(policyDest, readFileSync(policySrc));
178
+ wrote.push(policyDest);
179
+ }
180
+ const envBody = [
181
+ `VERAX_ISSUER=${ISSUER}`,
182
+ `VERAX_JWKS_FILE=${jwksPath}`,
183
+ `VERAX_AUDIENCE=${origin}`,
184
+ `VERAX_STATE_DIR=${stateDir}`,
185
+ `VERAX_POLICY_FILE=${policyDest}`,
186
+ `VERAX_BIND=127.0.0.1:${parsed.port}`,
187
+ "",
188
+ ].join("\n");
189
+ writeFileSync(envPath, envBody, { encoding: "utf8" });
190
+ wrote.push(envPath);
191
+ const lines = [
192
+ ...wrote.map((p) => `wrote ${p}`),
193
+ "",
194
+ "Start the body:",
195
+ `verax serve --env-file ${shQuote(envPath)}`,
196
+ "",
197
+ "Claude Code (sh):",
198
+ `claude mcp add --transport http verax ${origin}/mcp --header "Authorization: Bearer $(cat ${shQuote(tokenPath)})"`,
199
+ "",
200
+ "Claude Code (PowerShell):",
201
+ `claude mcp add --transport http verax ${origin}/mcp --header "Authorization: Bearer $(Get-Content -Raw ${psQuote(tokenPath)})"`,
202
+ "",
203
+ "Cursor / generic mcp.json:",
204
+ `{
205
+ "mcpServers": {
206
+ "verax": {
207
+ "url": "${origin}/mcp",
208
+ "headers": { "Authorization": "Bearer <token from your issuer>" }
209
+ }
210
+ }
211
+ }`,
212
+ `The token is the content of ${tokenPath}.`,
213
+ "",
214
+ "A held call is approved on this machine with: verax approve",
215
+ "",
216
+ ];
217
+ io.stdout.write(lines.join("\n"));
218
+ return 0;
219
+ }
package/dist/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { JwksFileError } from "./auth.js";
1
2
  import { EX_CONFIG, loadConfig, overlayInventoryArg } from "./config.js";
2
3
  import { KeysPartialError } from "./keys.js";
3
4
  import { listen } from "./server.js";
@@ -25,6 +26,10 @@ export async function main(env = process.env, argv = process.argv) {
25
26
  process.stderr.write("keys-partial\n");
26
27
  process.exit(err.code);
27
28
  }
29
+ if (err instanceof JwksFileError) {
30
+ process.stderr.write(`${err.message}\n`);
31
+ process.exit(err.code);
32
+ }
28
33
  throw err;
29
34
  }
30
35
  }
package/dist/server.js CHANGED
@@ -308,7 +308,7 @@ export async function listen(config) {
308
308
  throw err;
309
309
  }
310
310
  const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
311
- const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
311
+ const verify = createVerifier(config.jwksUrl, config.issuer, config.audience, config.jwksFile);
312
312
  const attachHandlers = (mcp) => {
313
313
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
314
314
  mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/body",
3
- "version": "0.2.1",
3
+ "version": "0.3.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.2.1",
26
- "@verax-ai/proxy": "^0.2.1",
25
+ "@verax-ai/inventory": "^0.3.0",
26
+ "@verax-ai/proxy": "^0.3.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.",