/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { escapeControlCharsGraphQL } from "./control-chars.js"; import { UserInputError } from "./errors.js"; import { assertGraphqlName, GRAPHQL_NAME_RE } from "./graphql-name.js"; import type { QuerySession, ProjectionNode, DirectiveNode } from "./session.js"; import { getChildren, getEffectiveArgs } from "./session.js"; /** * Renders a QuerySession's selection tree into a properly formatted GraphQL query string. * * @throws if any emitted GraphQL Name (operation name, variable name, field * name, alias, inline-fragment type condition, or directive name) is not a * valid GraphQL Name, if a variable's type reference has a non-Name innermost * NamedType, or if a `{`/`[`-prefixed argument/default value is not valid JSON * — the W-23204027 render-layer fail-safe. This fires only on a programmer * error (a builder/CLI path that stored a raw identifier without its own guard) * or a hostile input that slipped past the per-builder guards; every legitimate * value passes. Name violations throw an `Error` whose message matches * USER_INPUT_RE; the JSON-literal violation throws a typed `UserInputError` — * both classify as UserInput at the MCP boundary (`runTool`). */ export function renderQuery(session: QuerySession): string { const parts: string[] = []; // Operation line with variables const varDefs = session.variables.length > 0 ? `(${session.variables .map((v) => { // Fail-safe: the variable NAME is a GraphQL Name position emitted // verbatim as `$`. The TYPE is also emitted verbatim, but it // is not a bare Name — it carries type-reference syntax (`!`, `[]`) // — so it is guarded structurally by assertGraphqlType, which walks // the wrappers and asserts only the innermost NamedType. assertGraphqlName(v.name, "renderQuery", "variableName"); assertGraphqlType(v.type, "renderQuery"); let def = `$${v.name}: ${v.type}`; // W-23204027 (PR #694 review): the default VALUE is a value position, // not a Name — it must be formatted like any other arg value, not // concatenated raw. Raw emission both breaks legitimate output (a // multi-word string default renders `= Acme Corp`, which fails to // parse) and is a live selection-set/operation injection sink: a // default of `5) { stolen { Id } } query Decoy($z: Int` (reachable // via `sf_gql_raw`'s `var $x ''`) would otherwise // render a second, attacker-controlled operation. formatArgValue // quotes strings, passes through numbers/enums/bools/`$refs`, and // routes `{`/`[` defaults through jsonToGraphQL, which either emits // an arg-key-guarded input-object literal (valid JSON) or throws // (invalid JSON — see Round 3 fix in jsonToGraphQL). No variant // reaches raw emission. if (v.defaultValue !== undefined) def += ` = ${formatArgValue(v.defaultValue)}`; return def; }) .join(", ")})` : ""; const operationKeyword = session.operation === "aggregate" ? "query" : session.operation; // Fail-safe: operationName is a GraphQL Name position emitted verbatim in the // operation header. Guarded at every builder, but a deserialized/migrated // session (loadSession reads it from disk with no re-validation) or a future // builder could bypass that — so backstop it here too. if (session.operationName) { assertGraphqlName(session.operationName, "renderQuery", "operationName"); } const operationName = session.operationName ? ` ${session.operationName}` : ""; const operationBody = renderChildren(session, null, 1); if (operationBody) { parts.push(`${operationKeyword}${operationName}${varDefs} {`); parts.push(operationBody); parts.push("}"); } else { parts.push(`${operationKeyword}${operationName}${varDefs} { }`); } return parts.join("\n"); } /** * W-23204027 render-layer fail-safe for GraphQL argument KEYS. An argument key * is emitted verbatim as `: ` in three places — a field's argument * names (renderField), a directive's argument names (renderDirective), and the * keys of a nested input-object literal (valueToGraphQL). The last is * attacker-reachable today: `sf_gql_list` / `sf_gql_aggregate` accept a `filter` * / `orderBy` typed as `z.record(z.unknown())` (no charset on keys), the builder * `JSON.stringify`s it into an arg value, and valueToGraphQL then emits each * input field name / operator as a key. A key such as * `Name: {eq:"x"} }) { edges { node { Id } } } evilAlias: accounts(where: { Industry` * would otherwise render a fully parseable, schema-valid second connection — * a silent selection-set injection. Keys are NOT validated at the builder or * zod layer, so this render-layer assert is the only universal choke point * (everything funnels through the renderer). Every legitimate key — operators * (eq/ne/and/or/not/…), SObject field API names incl. `Custom__c`, connection * args — is a valid GraphQL Name, so this never fires on real input. */ function assertArgumentKey(key: string, emitter: string): void { assertGraphqlName(key, emitter, "argumentKey"); } /** * W-23204027 render-layer fail-safe for a variable's TYPE reference. Unlike a * Name position, a type carries GraphQL type-reference syntax (`!` for * non-null, `[...]` for lists) and so cannot be a bare `assertGraphqlName` — * that would wrongly reject legitimate types like `Int!` or `[ID!]!`. Instead * we walk the type-reference grammar structurally (October 2021 §2.11): strip a * trailing non-null `!`, unwrap a `[ ... ]` list wrapper (recursing on the inner * type), and finally assert the innermost NamedType is a valid GraphQL Name. * * This is a defense-in-depth backstop, not a live-reachable sink today: every * `addVariable` call site derives the type from schema inference, * `createInputTypeName`, or a hardcoded scalar — none accept a raw type from the * agent (the CLI `var`/`define` verbs set only the NAME, never the type). It * guards the residual paths a Name backstop would otherwise miss: a * deserialized/migrated session (`loadSession` does no type re-validation) or a * future builder that stores a raw type. Without it, a type such as * `Int) { evil { id } } query Decoy($z: Int` emitted verbatim as `$v: ` * breaks out into a second operation with no default value needed. */ function assertGraphqlType(type: string, emitter: string): void { let inner = type.trim(); // Peel any number of non-null / list wrappers from the outside in. while (true) { if (inner.endsWith("!")) { inner = inner.slice(0, -1).trim(); continue; } if (inner.startsWith("[") && inner.endsWith("]")) { inner = inner.slice(1, -1).trim(); continue; } break; } assertGraphqlName(inner, emitter, "variableType"); } function renderChildren(session: QuerySession, parentId: string | null, depth: number): string { const _indent = " ".repeat(depth); const lines: string[] = []; for (const child of getChildren(session, parentId)) { if (child.kind === "field") { lines.push(renderField(session, child, depth)); } else { lines.push(renderInlineFragment(session, child, depth)); } } return lines.join("\n"); } function renderField( session: QuerySession, node: Extract, depth: number, ): string { const indent = " ".repeat(depth); let line = indent; // W-23204027 render-layer fail-safe: assert every emitted GraphQL Name is // valid, making "the renderer never emits an injectable identifier" a // system-wide invariant layered UNDER the per-builder guards (W-22735537), // not replacing them. Fires only on a programmer error — a future builder // that calls selectLeaf/selectDottedFieldPath and forgets its assert — so // failing loud is correct. The message matches USER_INPUT_RE, so the MCP // path classifies it as UserInput rather than crashing. Argument *keys* are // covered too: they are emitted verbatim as `: ` here and in // renderDirective/valueToGraphQL, so a malicious filter/orderBy key (a // z.record(z.unknown()) with no charset at the schema boundary — see // assertArgumentKey) can otherwise break out of the argument object into // the selection set. That sink is NOT closed at the builder or zod layer. if (node.alias) { assertGraphqlName(node.alias, "renderField", "alias"); line += `${node.alias}: `; } assertGraphqlName(node.fieldName, "renderField", "fieldName"); line += node.fieldName; // Arguments const argEntries = Object.entries(getEffectiveArgs(session, node)); if (argEntries.length > 0) { const argParts = argEntries.map(([name, value]) => { assertArgumentKey(name, "renderField"); return `${name}: ${formatArgValue(value)}`; }); line += `(${argParts.join(", ")})`; } // Directives for (const dir of node.directives) { line += ` ${renderDirective(dir)}`; } // Sub-selections const childNodes = getChildren(session, node.id); const hasChildren = childNodes.length > 0; if (hasChildren) { const childContent = renderChildren(session, node.id, depth + 1); line += ` {\n${childContent}\n${indent}}`; } return line; } function renderInlineFragment( session: QuerySession, frag: Extract, depth: number, ): string { const indent = " ".repeat(depth); // W-23204027 render-layer fail-safe (see renderField): a type condition is a // GraphQL Name position emitted verbatim after `... on `. assertGraphqlName(frag.onType, "renderInlineFragment", "onType"); let line = `${indent}... on ${frag.onType}`; for (const dir of frag.directives) { line += ` ${renderDirective(dir)}`; } const childContent = renderChildren(session, frag.id, depth + 1); if (childContent) { line += ` {\n${childContent}\n${indent}}`; } else { line += " { }"; } return line; } function renderDirective(dir: DirectiveNode): string { // W-23204027 render-layer fail-safe (see renderField): a directive name is a // GraphQL Name position emitted verbatim after `@`. The only directive the // declarative/MCP surface adds is `@optional` (a valid Name), so this never // fires on legitimate output. assertGraphqlName(dir.name, "renderDirective", "directiveName"); const argEntries = Object.entries(dir.args); if (argEntries.length === 0) { return `@${dir.name}`; } const argParts = argEntries.map(([name, value]) => { assertArgumentKey(name, "renderDirective"); return `${name}: ${formatArgValue(value)}`; }); return `@${dir.name}(${argParts.join(", ")})`; } /** * Formats an argument value for rendering in GraphQL. * Handles variable references ($varName), raw JSON objects, numbers, booleans, and strings. */ function formatArgValue(value: string): string { const trimmed = value.trim(); // Variable reference — only when the name is a valid GraphQL Name. A typo'd // $-string (e.g. "$1var", "$foo-bar") falls through and is quoted as a literal // rather than emitted as an undeclared bare variable reference. if (trimmed.startsWith("$") && GRAPHQL_NAME_RE.test(trimmed.slice(1))) return trimmed; // Numeric if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed; // Boolean / null if (trimmed === "true" || trimmed === "false" || trimmed === "null") return trimmed; // Enum value (unquoted identifier) if (/^[A-Z_][A-Z0-9_]*$/i.test(trimmed) && trimmed === trimmed.toUpperCase()) return trimmed; // JSON object or array — convert to GraphQL literal syntax if (trimmed.startsWith("{") || trimmed.startsWith("[")) { return jsonToGraphQL(trimmed); } // Quoted string — pass through ONLY when it is a single, well-formed string // literal. W-23204027 (PR #694 review): a bare `startsWith('"') && endsWith('"')` // check is an injection hole — a payload like // `"a") { stolen } query Y($q: String = "b"` also starts and ends with a quote // yet breaks out of the value into a second operation. JSON.parse yielding a // string proves the whole token is ONE literal (interior quotes are escaped); // anything else falls through to JSON.stringify, which re-encodes it as a safe // single literal. if (trimmed.startsWith('"') && trimmed.endsWith('"')) { try { // W-23336442: even a well-formed single literal can carry raw DEL/Cf // inside the quotes (JSON permits them raw), so escape before passthrough // (see the default branch below for the full rationale). if (typeof JSON.parse(trimmed) === "string") return escapeControlCharsGraphQL(trimmed); } catch { // Not a single well-formed literal — re-encode below. } } // Default: a string literal. JSON.stringify produces a spec-valid GraphQL string // literal — it escapes line terminators and control chars (\n \r \t \b \f) and // quotes/backslashes — so values like "a\nb" don't render a raw newline that // graphql.parse() would reject as an unterminated string. (U+2028/U+2029 pass // through raw and are handled at the MCP text boundary, not here.) // // W-23336442: JSON.stringify escapes ONLY the C0 range (U+0000-U+001F); it // leaves DEL (U+007F) and the entire Cf class (bidi overrides, zero-width, BOM, // tag block) RAW inside the quoted literal. This query is a LIVE GraphQL // document reflected to the host, so those survivors would smuggle/reorder text // (sf_gql_list scope/filter/orderBy values reach here verbatim). Post-pass with // escapeControlCharsGraphQL, which emits GraphQL-valid `\uXXXX` (never `\xNN`, // which GraphQL rejects) so the query still graphql.parse()s. return escapeControlCharsGraphQL(JSON.stringify(trimmed)); } /** * Converts a JSON string to GraphQL object literal syntax. * GraphQL uses unquoted keys: { Status: { ne: "Closed" } } */ function jsonToGraphQL(jsonStr: string): string { let parsed: unknown; try { parsed = JSON.parse(jsonStr); } catch { // W-23204027 (PR #694 review, Round 3): REJECT — do NOT return the raw // string. `formatArgValue` only routes here when the value starts with `{` // or `[`, so a JSON.parse failure means it is a `{`/`[`-prefixed string // that is NOT well-formed JSON — never legitimate GraphQL. Every real // producer of a `{`/`[` value delivers valid JSON (the builders // `JSON.stringify` filter/orderBy; the CLI `set`/`assign` path // JSON-validates `{`/`[` literals in `validateLiteralAssignment` before // storing), so this rejects zero legitimate flows. Returning it verbatim // bypassed valueToGraphQL's `assertArgumentKey`, making it a live // selection-set/operation injection sink: a variable default of // `{ minRevenue: 0 }) { edges { node { id } } } } query Decoy($z: Filter` // (unquoted key ⇒ invalid JSON ⇒ this catch; reachable via `sf_gql_raw`'s // `var $x ''`) rendered a second, attacker-controlled // operation. Throw a typed UserInputError (NOT a bare Error whose text // would miss USER_INPUT_RE and misclassify as Internal) so `runTool` // classifies it UserInput. throw new UserInputError( `jsonToGraphQL: value beginning with '{' or '[' is not valid JSON and cannot be rendered as a GraphQL literal: ${jsonStr.slice(0, 60)}`, ); } // valueToGraphQL runs OUTSIDE the try: it enforces the W-23204027 arg-key // fail-safe by throwing on a malicious input-object key, and that assertion // must propagate — not be swallowed and fall back to emitting the raw // (injectable) string. return valueToGraphQL(parsed); } function valueToGraphQL(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); if (typeof value === "string") { // Bare variable reference only when the name is a valid GraphQL Name; // otherwise quote it as a literal (a typo'd $-string is not a declared var). if (value.startsWith("$") && GRAPHQL_NAME_RE.test(value.slice(1))) return value; // Emit uppercase identifiers as bare enum tokens (e.g. DESC, ASC, EVERYTHING) if (/^[A-Z_][A-Z0-9_]*$/.test(value)) return value; // JSON.stringify yields a spec-valid GraphQL string literal that escapes line // terminators and control chars (\n \r \t \b \f), unlike a manual \ / " escape // which would leave a raw newline that graphql.parse() rejects. (U+2028/U+2029 // pass through raw and are handled at the MCP text boundary, not here.) // // W-23336442: JSON.stringify escapes only C0, leaving DEL (U+007F) + the Cf // class raw inside the literal. This is a nested input-object string value in // a LIVE GraphQL document (filter/orderBy values JSON.stringify'd into a // `where` arg reach here), so post-pass with escapeControlCharsGraphQL to emit // GraphQL-valid `\uXXXX` (never `\xNN`) — same rationale as formatArgValue. return escapeControlCharsGraphQL(JSON.stringify(value)); } if (Array.isArray(value)) { return `[${value.map(valueToGraphQL).join(", ")}]`; } if (typeof value === "object") { const entries = Object.entries(value as Record); const parts = entries.map(([k, v]) => { // The live arg-key injection sink: filter/orderBy objects are // JSON.stringify'd into an arg value by the builders, so every input // field name / operator here is an attacker-controllable key emitted // verbatim into a GraphQL input-object literal. Guard it (see // assertArgumentKey). Array elements never reach this branch as keys — // the Array case above emits `[...]` with no `:`. assertArgumentKey(k, "valueToGraphQL"); return `${k}: ${valueToGraphQL(v)}`; }); return `{ ${parts.join(", ")} }`; } return String(value); }