@verax-ai/body 0.1.4 → 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/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;
@@ -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.4",
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.",