UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

194 lines (193 loc) • 9.09 kB
/** * Tool policy resolution — the single, pure mapping from every tool-selection * surface (per-call legacy options + instance-level `tools` config) to one * ResolvedToolPolicy that the gate applies. * * Legacy semantics are preserved deliberately: * - `toolFilter: []` and `enabledToolNames: []` are no-ops (fail-open), as * they always were. * - Malformed shapes (non-string-arrays) are ignored with a once-per-process * WARN instead of throwing — some internal callers historically passed * wrong shapes and relied on the silent no-op. * - `enabledToolNames` now filters the native tool set (its docs always * promised this; it previously only filtered the system-prompt listing) — * but ONLY when `toolFilter` is absent. When both are set, `toolFilter` * alone bounds the native set, exactly as before this refactor: the native * set must never become WIDER than a caller's `toolFilter`. * * New semantics: * - `tools.include: []` (the instance config surface) means NO tools * (fail-closed); legacy empty arrays stay fail-open as above. * - A per-call `toolFilter` is bounded by `tools.include` — it can narrow * the instance allowlist but never widen past it. * - Pattern entries containing `*` match as globs (e.g. `"github*"`) on ALL * surfaces. Real tool names cannot contain `*` (MCP names are sanitized), * so a legacy list entry with `*` previously matched nothing (dead entry); * it now matches as a glob. Entries without `*` match exactly, unchanged. */ import { logger } from "../utils/logger.js"; const warnedOnce = new Set(); function warnOnce(key, message, detail) { if (!warnedOnce.has(key)) { warnedOnce.add(key); logger.warn(message, detail); } } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Compile a list of tool-name patterns into a matcher. Patterns without `*` * match exactly (case-sensitive, preserving legacy toolFilter semantics); * patterns containing `*` match as globs. */ export function toolNameMatcher(patterns) { const exact = new Set(); const globs = []; for (const pattern of patterns) { if (pattern.includes("*")) { // Split on RUNS of asterisks: "a**b" must compile to /^a.*b$/, not // /^a.*.*b$/ — adjacent .* pairs invite catastrophic backtracking on // long non-matching names (ReDoS). const source = "^" + pattern.split(/\*+/).map(escapeRegExp).join(".*") + "$"; globs.push(new RegExp(source)); } else { exact.add(pattern); } } return (name) => exact.has(name) || globs.some((g) => g.test(name)); } /** * Normalize a possibly-malformed option value to a string array. * Returns undefined (and WARNs once per source) for anything that is not a * proper string array — preserving the historical silent-no-op behavior for * wrong shapes while making it observable. Used for the LEGACY per-call * surfaces only; the new config surface uses normalizeConfigList, which does * not fail open. */ function asStringArray(value, sourceName) { if (value === undefined || value === null) { return undefined; } if (Array.isArray(value) && value.every((v) => typeof v === "string")) { return value; } warnOnce(`shape:${sourceName}`, `[ToolPolicy] Ignoring ${sourceName}: expected string[]`, { receivedType: typeof value }); return undefined; } /** * Normalize a `tools.include`/`tools.exclude` config value. Unlike the * legacy surfaces, the NEW config surface must not silently fail open — a * caller who fat-fingers `tools.include` intending to lock an instance down * must not get every tool exposed: * - a bare string is coerced to a one-element list (obvious intent), * - an array is salvaged to its string entries (dropping the rest, WARNed — * for `include` this converges toward fewer tools, never more), * - anything else fails CLOSED for `include` (empty list = no tools) and is * ignored-with-WARN for `exclude` (a denylist has no safe "closed" other * than excluding everything, which would be equally surprising). */ function normalizeConfigList(value, sourceName, failClosed) { if (value === undefined || value === null) { return undefined; } if (typeof value === "string") { warnOnce(`shape:${sourceName}`, `[ToolPolicy] ${sourceName} was a bare string; coercing to a one-element list`, { received: value }); return [value]; } if (Array.isArray(value)) { const strings = value.filter((v) => typeof v === "string"); if (strings.length !== value.length) { warnOnce(`shape:${sourceName}`, `[ToolPolicy] Dropped ${value.length - strings.length} non-string entr(ies) from ${sourceName}`); } return strings; } warnOnce(`shape:${sourceName}`, failClosed ? `[ToolPolicy] ${sourceName} is malformed (expected string[]); failing CLOSED — no tools will be sent until it is fixed` : `[ToolPolicy] Ignoring malformed ${sourceName}: expected string[]`, { receivedType: typeof value }); return failClosed ? [] : undefined; } /** * Resolve the effective tool policy for one request. Pure function — all * inputs are explicit, so the legacy→policy mapping is unit-testable as a * table. */ export function resolveToolPolicy(input) { const { options, instanceConfig, builtinToolNames } = input; const sources = []; // --- enabled ------------------------------------------------------------- let enabled = true; if (options.disableTools === true) { enabled = false; sources.push("options.disableTools"); } if (instanceConfig?.enabled === false) { enabled = false; sources.push("tools.enabled"); } // --- allowlist ----------------------------------------------------------- // Legacy per-call allowlists: fail-open on empty (historical behavior). const toolFilter = asStringArray(options.toolFilter, "options.toolFilter"); const enabledNames = asStringArray(options.enabledToolNames, "options.enabledToolNames"); let legacyInclude; if (toolFilter && toolFilter.length > 0) { // toolFilter alone bounds the native set — merging enabledToolNames in // as a union would WIDEN the set beyond toolFilter, breaking the // pre-refactor contract in the unsafe direction. legacyInclude = [...new Set(toolFilter)]; sources.push("options.toolFilter"); if (enabledNames && enabledNames.length > 0) { warnOnce("both-allowlists", "[ToolPolicy] Both toolFilter and enabledToolNames are set; toolFilter bounds the native tool set and enabledToolNames is ignored for it (pre-existing contract)."); } } else if (enabledNames && enabledNames.length > 0) { legacyInclude = [...new Set(enabledNames)]; sources.push("options.enabledToolNames"); } // New instance allowlist: fail-closed on empty ([] = no tools) AND on // malformed shapes — a broken lockdown config must never expose all tools. const configInclude = normalizeConfigList(instanceConfig?.include, "tools.include", true); if (configInclude !== undefined) { sources.push("tools.include"); } let include; let includeBound; if (legacyInclude !== undefined && configInclude !== undefined) { // Per-call filter is bounded by the instance allowlist: a tool must // match BOTH lists. Kept as two matcher clauses (include AND // includeBound) because glob pattern lists cannot be losslessly // pre-intersected into one pattern array — filtering one list's pattern // STRINGS through the other list's matcher would zero out overlapping // globs like toolFilter:["github*"] vs include:["github_read*"]. include = legacyInclude; includeBound = configInclude; } else { include = legacyInclude ?? configInclude; } // --- denylist ------------------------------------------------------------ const exclude = []; const optExclude = asStringArray(options.excludeTools, "options.excludeTools"); if (optExclude && optExclude.length > 0) { exclude.push(...optExclude); sources.push("options.excludeTools"); } const cfgExclude = normalizeConfigList(instanceConfig?.exclude, "tools.exclude", false); if (cfgExclude && cfgExclude.length > 0) { exclude.push(...cfgExclude); sources.push("tools.exclude"); } if (instanceConfig?.disableBuiltinTools === true && builtinToolNames && builtinToolNames.length > 0) { exclude.push(...builtinToolNames); sources.push("tools.disableBuiltinTools"); } // --- discovery ----------------------------------------------------------- const discovery = instanceConfig?.discovery === true; if (discovery) { sources.push("tools.discovery"); } return { enabled, include, includeBound, exclude, discovery, sources }; }