/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * FLS-safe field selection policy for the declarative MCP tools (W-22818723). * * Salesforce UIAPI hard-fails an ENTIRE GraphQL query when it selects a field * the running user lacks FLS for ("FieldUndefined … Field 'Gold__c'", data:{}). * The `@optional` directive is UIAPI's graceful-degradation mechanism: a field * marked `@optional` is silently omitted from the response instead of killing * the query. Proven at runtime against `Hero__c.Gold__c` in the W-22800643 QA. * * Decision (W-22818723): the declarative tools default to applying `@optional` * to every selected SObject field that FLS can actually gate, and select both * `value` and `displayValue` on value-wrapper fields wherever the wrapper * exposes a `displayValue`. There is no opt-in flag — degradation-by-default is * the safe behavior for an LLM driving queries across multi-user orgs where FLS * varies per user. Fields FLS can never hide (`Id`) are exempt — see * `FLS_EXEMPT_FIELDS`. * * This policy runs once on the assembled session inside `buildOutput` (the * shared finalizer for `buildList`/`buildDetail`/`buildAggregate`/ * `buildMutation`/`buildRaw`/`buildDelete`), so all six tools inherit it * uniformly. The interactive CLI does NOT route through `buildOutput`, so its * explicit `optional` verb keeps full manual control — this default is * MCP-surface-only. */ import { isInterfaceType, isObjectType, type GraphQLSchema } from "graphql"; import { type FieldProjectionNode, type ProjectionNode, type QuerySession, getChildren, getNodeById, selectLeaf, } from "./session.js"; import { isValueWrapperType } from "./uiapi.js"; import { resolvePath } from "./walker.js"; /** The UIAPI FLS-degradation directive name (matches the CLI `optional` verb). */ export const OPTIONAL_DIRECTIVE = "optional"; /** The companion display leaf selected alongside `value` on value wrappers. */ const DISPLAY_VALUE_FIELD = "displayValue"; /** * Record fields FLS can never hide from a user who can read the record, so * `@optional` on them is a guaranteed no-op for degradation. Marking them would * only weaken the generated type (`Id?: string | undefined` instead of the * always-present `Id: string`), so the policy skips them. `Id` is the one such * field today; broader system/audit fields are a deliberate follow-up * (see W-22818723 PR #648). */ const FLS_EXEMPT_FIELDS = new Set(["Id"]); /** * Type-name suffixes / names that are UIAPI *structure* rather than record * fields. A bare scalar whose parent resolves to one of these is plumbing * (cursor pagination, connection wrappers, aggregate result envelopes), not an * FLS-gated SObject field, so it must NOT receive `@optional`. */ function isStructuralScope(schema: GraphQLSchema, typeName: string): boolean { if (!typeName) return true; if (typeName === "PageInfo") return true; if ( typeName.endsWith("Connection") || typeName.endsWith("Edge") || typeName.endsWith("Aggregate") ) { return true; } // Value wrappers are handled by the wrapper branch; their `value` / // `displayValue` leaves must not be independently marked. if (isValueWrapperType(schema, typeName)) return true; return false; } /** * A "record scope" is an object/interface type that represents a Salesforce * record (the `node` inside a connection, a mutation `Record`, a parent * relationship object, a polymorphic union member fragment) — i.e. somewhere a * directly-selected scalar like `Id` is an FLS-gated field. Structural * envelopes (`*Connection` / `*Edge` / `*Aggregate` / `PageInfo`) and value * wrappers are NOT record scopes: their children are plumbing (cursors, * aggregation functions, `value` / `displayValue` leaves), which FLS does not * gate and `@optional` must not touch. * * Resolving by the PARENT scope — rather than the field's own type — is what * keeps the aggregate subtree clean: `count`/`sum`/… resolve to value-wrapper * types (`LongValue`, …) but hang off an `*Aggregate` parent, so they are * correctly excluded. */ function isRecordScope( session: QuerySession, schema: GraphQLSchema, parent: ProjectionNode | null, ): boolean { if (!parent) return false; let typeName: string; if (parent.kind === "fragment") { // `... on User { … }` — the union member type IS the record scope. typeName = parent.onType; } else { try { typeName = resolvePath(schema, session.operation, parent.schemaPath).typeName; } catch { return false; } } if (isStructuralScope(schema, typeName)) return false; const t = schema.getType(typeName); return isObjectType(t) || isInterfaceType(t); } /** Idempotently attaches the `@optional` directive to a field node. */ function markOptional(node: FieldProjectionNode): void { if (!node.directives.some((d) => d.name === OPTIONAL_DIRECTIVE)) { node.directives.push({ name: OPTIONAL_DIRECTIVE, args: {} }); } } /** * Selects `displayValue` under a value-wrapper field when (a) the wrapper type * actually exposes a `displayValue` field in the schema and (b) it is not * already selected. This is the "include display value where it can be * included" half of the AC — minimal test schemas whose wrapper is `{ value }` * only are left untouched; real UIAPI wrappers (`StringValue`, * `PicklistValue`, …) gain it. */ function ensureDisplayValue( session: QuerySession, schema: GraphQLSchema, wrapperNode: FieldProjectionNode, wrapperTypeName: string, ): void { const t = schema.getType(wrapperTypeName); if (!isObjectType(t) && !isInterfaceType(t)) return; if (!Object.prototype.hasOwnProperty.call(t.getFields(), DISPLAY_VALUE_FIELD)) return; const alreadySelected = getChildren(session, wrapperNode.id).some( (c) => c.kind === "field" && c.fieldName === DISPLAY_VALUE_FIELD, ); if (alreadySelected) return; selectLeaf(session, [...wrapperNode.schemaPath, DISPLAY_VALUE_FIELD]); } /** * Applies the global declarative-tool schema policies to a fully-assembled * session in place — both the FLS-safe `@optional` default and `displayValue` * selection on value wrappers: * - value-wrapper fields → `@optional` + `value` + `displayValue` (where exposed) * - bare record scalars → `@optional` (except `FLS_EXEMPT_FIELDS`, e.g. `Id`) * - structural plumbing → untouched (`pageInfo`, `edges`, `node`, cursors, * aggregate envelopes, the `value`/`displayValue` * leaves themselves) * * Unresolvable nodes are left as-is — the renderer/validator surfaces a clearer * error than this pass could. Never throws. */ export function applyGlobalSchemaPolicies(session: QuerySession, schema: GraphQLSchema): void { // Snapshot field nodes up front: `ensureDisplayValue` appends new leaves to // `session.nodes`, and a freshly-added `displayValue` must not be reprocessed. const fieldNodes = session.nodes.filter((n): n is FieldProjectionNode => n.kind === "field"); for (const node of fieldNodes) { let typeName: string; let isLeaf: boolean; try { const wr = resolvePath(schema, session.operation, node.schemaPath); typeName = wr.typeName; isLeaf = wr.isLeaf; } catch { continue; } // A field is FLS-gated — and thus a policy target — only when it is a // direct field of a record scope. This single gate covers both shapes: // value-wrapper fields (`Name { value }`) and bare record scalars // (`Id`). It is what excludes the aggregate function-wrappers // (`count`/`sum`/…), whose parent is an `*Aggregate` structural scope. const parent = getNodeById(session, node.parentId); if (!isRecordScope(session, schema, parent)) continue; if (isValueWrapperType(schema, typeName)) { markOptional(node); ensureDisplayValue(session, schema, node, typeName); } else if (isLeaf && !FLS_EXEMPT_FIELDS.has(node.fieldName)) { // Bare record scalar. Skip fields FLS can never gate (`Id`): marking // them is a no-op that would only weaken the generated type. markOptional(node); } } }