Skip to content

Commit ed85ca2

Browse files
hyperpolymathclaude
andcommitted
feat(mcp-bridge): runtime Nickel envelope validation (Task #17)
New lib/nickel-validator.js shells out to \`nickel eval\` with contracts.validate from coord-messages-contracts.ncl. Runs in dispatchLocalCoord BEFORE forwarding coord_send / coord_send_gated to the Zig adapter — invalid envelopes get rejected with a descriptive MCP error ("envelope validation failed: ...") that points at the offending contract. Only coord_send and coord_send_gated carry A2ML envelopes — other coord_* tools are RPC-shaped and skip validation. Expansion to more tools tracked as a roadmap item. If \`nickel\` is missing on PATH, validation is skipped with a warning so coord stays usable in environments without Nickel. Set COORD_REQUIRE_NICKEL=1 to fail closed. 4 smoke tests in nickel-validator.test.js: path resolution, tier-0 accept, tier-2-missing-context reject, urgent_direct from supervised reject. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9e40a86 commit ed85ca2

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

mcp-bridge/lib/nickel-validator.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Nickel envelope validator — runtime check for coord_send / coord_send_gated
5+
// envelope bodies (Task #17).
6+
//
7+
// Before the mcp-bridge forwards a coord_send(_gated) call to the Zig
8+
// adapter, the incoming `message` payload is parsed (if JSON) and run
9+
// through `contracts.validate` from coord-messages-contracts.ncl via
10+
// a `nickel eval` subprocess. Invalid envelopes are rejected with a
11+
// descriptive MCP error — they never reach the loopback adapter.
12+
//
13+
// Design notes:
14+
//
15+
// * The Nickel CLI is invoked per-call. No long-running Nickel process
16+
// today; the contract file is small and Nickel eval is sub-10ms on
17+
// typical envelopes. If this becomes a bottleneck, a follow-up task
18+
// can batch / cache.
19+
//
20+
// * The contracts *path* is resolved once at bridge startup
21+
// (`contractsPath()`), so we don't pay disk lookup on every call.
22+
//
23+
// * If the `nickel` binary is not on PATH, validation is skipped with
24+
// a single warning — this keeps coord usable in environments where
25+
// Nickel isn't installed (contracts still run via the Zig adapter's
26+
// own gating). To fail closed instead of open, set
27+
// COORD_REQUIRE_NICKEL=1.
28+
//
29+
// * Only coord_send and coord_send_gated carry envelope bodies; other
30+
// coord_* tools are RPC-shaped and skip validation. Extension to
31+
// more tools is tracked in Appendix K of COORD-MCP-DESIGN-LOG.
32+
33+
import { spawnSync } from "node:child_process";
34+
import { existsSync } from "node:fs";
35+
import { fileURLToPath } from "node:url";
36+
import { dirname, resolve } from "node:path";
37+
import { writeFileSync, unlinkSync } from "node:fs";
38+
import { info, warn } from "./logger.js";
39+
40+
const CONTRACTS_REL = "../../cartridges/local-coord-mcp/schemas/coord-messages-contracts.ncl";
41+
42+
let cachedContractsPath = null;
43+
let nickelAvailable = null; // null = not yet probed, true = on PATH, false = missing
44+
45+
/**
46+
* Resolve the absolute contracts path once and cache it. Returns null
47+
* if the file isn't found (caller treats as "no validation configured").
48+
*/
49+
export function contractsPath() {
50+
if (cachedContractsPath !== null) return cachedContractsPath;
51+
52+
// main.js lives at mcp-bridge/main.js — lib/ is one below.
53+
const here = dirname(fileURLToPath(import.meta.url));
54+
const candidate = resolve(here, CONTRACTS_REL);
55+
cachedContractsPath = existsSync(candidate) ? candidate : null;
56+
return cachedContractsPath;
57+
}
58+
59+
/** Probe whether `nickel` is on PATH. Cached. */
60+
function probeNickel() {
61+
if (nickelAvailable !== null) return nickelAvailable;
62+
const r = spawnSync("nickel", ["--version"], { stdio: "ignore" });
63+
nickelAvailable = r.status === 0;
64+
if (!nickelAvailable) {
65+
const strict = process.env.COORD_REQUIRE_NICKEL === "1";
66+
if (strict) {
67+
throw new Error("COORD_REQUIRE_NICKEL=1 but `nickel` not on PATH");
68+
}
69+
warn("Nickel binary not found — coord envelope validation disabled");
70+
}
71+
return nickelAvailable;
72+
}
73+
74+
/**
75+
* Serialise a JS value into a Nickel literal that `nickel eval` can
76+
* parse. Covers the subset needed for coord envelopes: null, bool,
77+
* number (int/float), string, array, plain object. Other shapes are
78+
* serialised as their JSON form, which Nickel parses as records.
79+
*/
80+
function toNickel(v) {
81+
if (v === null || v === undefined) return "null";
82+
if (typeof v === "boolean") return v ? "true" : "false";
83+
if (typeof v === "number") {
84+
// Nickel accepts "1" and "1.5" literals directly.
85+
return Number.isFinite(v) ? String(v) : "null";
86+
}
87+
if (typeof v === "string") {
88+
// Use Nickel's multiline-string safe form via JSON.stringify —
89+
// Nickel string literal syntax is a superset of JSON's for basic
90+
// double-quoted strings (no unicode \u escapes unusual to Nickel).
91+
return JSON.stringify(v);
92+
}
93+
if (Array.isArray(v)) {
94+
return `[${v.map(toNickel).join(",")}]`;
95+
}
96+
if (typeof v === "object") {
97+
const entries = Object.entries(v)
98+
.filter(([, val]) => val !== undefined)
99+
.map(([k, val]) => `"${k}" = ${toNickel(val)}`);
100+
return `{${entries.join(",")}}`;
101+
}
102+
// Fallback: unknown type — stringify to JSON so Nickel at least
103+
// parses it as a string.
104+
return JSON.stringify(String(v));
105+
}
106+
107+
/**
108+
* Run contracts.validate on `envelope`. Returns {ok: true} if valid,
109+
* or {ok: false, error: string} if Nickel rejects the envelope.
110+
*
111+
* If Nickel isn't available (and COORD_REQUIRE_NICKEL != 1), returns
112+
* {ok: true, skipped: true}.
113+
*/
114+
export function validateEnvelope(envelope, senderRole) {
115+
if (!probeNickel()) return { ok: true, skipped: true };
116+
const path = contractsPath();
117+
if (!path) return { ok: true, skipped: true };
118+
119+
// Attach sender_role into _meta so role-dependent contracts
120+
// (TierAttestationGate / UrgentDirectRestriction) can evaluate.
121+
const withMeta = { ...envelope };
122+
if (senderRole) {
123+
withMeta._meta = { ...(withMeta._meta || {}), sender_role: senderRole };
124+
}
125+
126+
// Write the call-site script to a temp file in the same dir as the
127+
// contracts so its relative `import` resolves.
128+
const tmp = resolve(dirname(path), `._validate_${process.pid}_${Date.now()}.ncl`);
129+
const script =
130+
`let c = import "${path.split("/").pop()}" in\n` +
131+
`let e = ${toNickel(withMeta)} in\n` +
132+
`c.validate e\n`;
133+
134+
try {
135+
writeFileSync(tmp, script);
136+
const r = spawnSync("nickel", ["eval", tmp], {
137+
cwd: dirname(path),
138+
encoding: "utf8",
139+
});
140+
if (r.status === 0) return { ok: true };
141+
// Nickel writes the violation to stderr; fall back to stdout.
142+
const err = (r.stderr || r.stdout || "").trim();
143+
// Squash to the most informative line.
144+
const firstLine = err.split("\n").find((l) => l.includes("error:")) || err.split("\n")[0] || "validation failed";
145+
return { ok: false, error: firstLine };
146+
} catch (e) {
147+
return { ok: false, error: `nickel invocation failed: ${e.message}` };
148+
} finally {
149+
try { unlinkSync(tmp); } catch { /* best-effort cleanup */ }
150+
}
151+
}
152+
153+
/**
154+
* Best-effort parse of the coord_send message body — coord envelopes
155+
* are typically JSON-stringified A2ML envelopes. Returns the parsed
156+
* object or null if the body isn't JSON-shaped.
157+
*/
158+
export function tryParseEnvelope(msg) {
159+
if (typeof msg !== "string") return msg && typeof msg === "object" ? msg : null;
160+
const s = msg.trim();
161+
if (!s.startsWith("{")) return null; // plain text payload — caller skips validation
162+
try {
163+
return JSON.parse(s);
164+
} catch {
165+
return null;
166+
}
167+
}
168+
169+
/**
170+
* Initialise validator state at bridge startup. Idempotent.
171+
* Logs an info line with the resolved state.
172+
*/
173+
export function initValidator() {
174+
const path = contractsPath();
175+
const ready = probeNickel();
176+
info("Nickel validator", {
177+
nickel: ready ? "available" : "missing",
178+
contracts: path ? "loaded" : "not_found",
179+
});
180+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Smoke test for the Nickel envelope validator (Task #17).
5+
// Run: node mcp-bridge/lib/nickel-validator.test.js
6+
//
7+
// Verifies:
8+
// 1. contractsPath() resolves to the real file.
9+
// 2. A valid envelope passes.
10+
// 3. A tier-2 envelope without context_fetch_id is rejected.
11+
// 4. urgent_direct from a supervised peer is rejected.
12+
13+
import { strict as assert } from "node:assert";
14+
import { contractsPath, validateEnvelope, initValidator } from "./nickel-validator.js";
15+
16+
let passed = 0;
17+
let failed = 0;
18+
19+
function t(name, fn) {
20+
try {
21+
fn();
22+
console.log(` PASS: ${name}`);
23+
passed++;
24+
} catch (e) {
25+
console.error(` FAIL: ${name}\n ${e.message}`);
26+
failed++;
27+
}
28+
}
29+
30+
console.log("=== Nickel validator smoke tests ===");
31+
32+
initValidator();
33+
34+
t("contractsPath resolves", () => {
35+
const p = contractsPath();
36+
assert.ok(p, "expected a resolved path, got null");
37+
assert.ok(p.endsWith("coord-messages-contracts.ncl"), `unexpected path: ${p}`);
38+
});
39+
40+
t("tier 0 status envelope passes", () => {
41+
const env = {
42+
version: 1,
43+
msg_id: "abcdef012345",
44+
prev_msg_hash: "0".repeat(64),
45+
sender: "claude-7f3a",
46+
recipient: "gemini-b2c1",
47+
timestamp: "2026-04-20T10:00:00Z",
48+
op_kind: "status",
49+
risk_tier: 0,
50+
payload: { status: "ok" },
51+
};
52+
const r = validateEnvelope(env, null);
53+
assert.ok(r.ok, `expected ok, got error: ${r.error}`);
54+
});
55+
56+
t("tier 2 without context_fetch_id is rejected", () => {
57+
const env = {
58+
version: 1,
59+
msg_id: "abcdef012346",
60+
prev_msg_hash: "0".repeat(64),
61+
sender: "claude-7f3a",
62+
recipient: "*",
63+
timestamp: "2026-04-20T10:00:00Z",
64+
op_kind: "claim",
65+
risk_tier: 2,
66+
payload: { task: "x" },
67+
};
68+
const r = validateEnvelope(env, null);
69+
if (r.skipped) {
70+
console.log(" (skipped: nickel not on PATH)");
71+
return;
72+
}
73+
assert.ok(!r.ok, "expected rejection, got ok");
74+
});
75+
76+
t("urgent_direct from supervised is rejected", () => {
77+
const env = {
78+
version: 1,
79+
msg_id: "abcdef012347",
80+
prev_msg_hash: "0".repeat(64),
81+
sender: "gemini-b2c1",
82+
recipient: "claude-7f3a",
83+
timestamp: "2026-04-20T10:00:00Z",
84+
op_kind: "clarify",
85+
risk_tier: 0,
86+
payload: { question: "hey" },
87+
urgent_direct: true,
88+
};
89+
const r = validateEnvelope(env, "supervised");
90+
if (r.skipped) {
91+
console.log(" (skipped: nickel not on PATH)");
92+
return;
93+
}
94+
assert.ok(!r.ok, "expected rejection, got ok");
95+
});
96+
97+
console.log(`\n=== ${passed} passed, ${failed} failed ===`);
98+
process.exit(failed === 0 ? 0 : 1);

mcp-bridge/main.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import {
3030
invokeCartridge,
3131
} from "./lib/api-clients.js";
3232
import { buildToolList } from "./lib/tools.js";
33+
import {
34+
initValidator,
35+
tryParseEnvelope,
36+
validateEnvelope,
37+
} from "./lib/nickel-validator.js";
3338
import { info, warn, error as logError } from "./lib/logger.js";
3439

3540
const BOJ_BASE = process.env.BOJ_URL || "http://localhost:7700";
@@ -257,7 +262,34 @@ async function dispatchTool(toolName, args) {
257262

258263
const LOCAL_COORD_URL = process.env.COORD_BACKEND_URL || "http://127.0.0.1:7745";
259264

265+
// Nickel contracts run on coord_send / coord_send_gated only — those
266+
// are the two tools whose `message` argument carries an A2ML envelope.
267+
// Other coord_* calls are RPC-shaped (register/list/review/approve/...)
268+
// and bypass contract validation. Expansion to more tools is a roadmap
269+
// item — see Appendix K of COORD-MCP-DESIGN-LOG.md (Task #17 extension).
270+
const ENVELOPE_CARRYING_TOOLS = new Set(["coord_send", "coord_send_gated"]);
271+
272+
initValidator();
273+
260274
async function dispatchLocalCoord(toolName, args) {
275+
// Runtime envelope validation (Task #17) — BEFORE the HTTP forward.
276+
if (ENVELOPE_CARRYING_TOOLS.has(toolName) && args && typeof args.message === "string") {
277+
const env = tryParseEnvelope(args.message);
278+
if (env && typeof env === "object") {
279+
const senderRole = env._meta?.sender_role || args.sender_role;
280+
const result = validateEnvelope(env, senderRole);
281+
if (!result.ok) {
282+
return {
283+
success: false,
284+
error: `envelope validation failed: ${result.error}`,
285+
hint: "See cartridges/local-coord-mcp/schemas/coord-messages-contracts.ncl for the active contracts",
286+
};
287+
}
288+
}
289+
// Plain-string messages (non-JSON) skip validation — they're not
290+
// A2ML envelopes. The Zig adapter still enforces shape + gating.
291+
}
292+
261293
try {
262294
const res = await fetch(`${LOCAL_COORD_URL}/tools/${toolName}`, {
263295
method: "POST",

0 commit comments

Comments
 (0)