|
| 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 | +} |
0 commit comments