From cfdc7721d23b5b4e5dc107889af42e834377700a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:56:11 +0000 Subject: [PATCH 1/6] feat(toolkit-core): Add shared Markdown builders for untrusted values Add `@sentry/toolkit-core/markdown` so the CLI and MCP render upstream data without hand-rolled escaping. Pick the helper by what the value is: - `inlineCode` for identifiers and short values. The fence is longer than any backtick run, padding keeps leading or trailing backticks and spaces, and line breaks become spaces. - `codeBlock` for multi-line data. The fence is longer than any backtick run, so no line of the value can close the block. - `text` for prose. It escapes with mdast-util-to-markdown and its GFM extension, and writes the first `w` of a `www.` domain as a character reference, since GFM reads that autolink from the raw source. Property tests parse each helper's output with micromark and check that any input round-trips as the same literal value with no other structure. They found the `www.` autolink gap. They also cover two gaps in MCP's existing code span helper: it dropped a leading and trailing space, and a line break in the value ended the line. MCP's two copies of `formatInlineCode` now use `inlineCode`. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/contributing/tool-responses.md | 6 + .../tool-helpers/ai-conversation-actions.ts | 14 +- .../tools/support/search-events/formatters.ts | 20 +-- packages/toolkit-core/README.md | 12 ++ packages/toolkit-core/package.json | 10 +- .../src/markdown.property.test.ts | 148 ++++++++++++++++++ packages/toolkit-core/src/markdown.test.ts | 59 +++++++ packages/toolkit-core/src/markdown.ts | 91 +++++++++++ pnpm-lock.yaml | 48 ++++-- pnpm-workspace.yaml | 4 + 10 files changed, 372 insertions(+), 40 deletions(-) create mode 100644 packages/toolkit-core/src/markdown.property.test.ts create mode 100644 packages/toolkit-core/src/markdown.test.ts create mode 100644 packages/toolkit-core/src/markdown.ts diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 020321803..a18ff3378 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -113,6 +113,12 @@ Translate upstream API shapes into user-facing terms: surrogate-aware slicing. - Prefer shared helpers for dates, actors, IDs, and unknown values. If a helper emits noisy placeholders for a domain-specific field, filter before calling it. +- Render untrusted values with `@sentry/toolkit-core/markdown`, chosen by what + the value is: `inlineCode` for identifiers, slugs, queries, and short values, + `codeBlock` for multi-line data such as messages and stack traces, and `text` + for values that should read as prose. Each keeps the value inside its line or + block, so upstream data cannot add headings, list items, or links. Prefer + `inlineCode`: it needs no escaping, so the model reads the value unchanged. When changing Sentry API endpoint usage, validate the upstream behavior in `~/src/sentry` as required by [../../AGENTS.md](../../AGENTS.md). API schemas diff --git a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts index bdaf17e49..2130306c1 100644 --- a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts +++ b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts @@ -1,3 +1,4 @@ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import { isTopLevelToolName } from "../../tools/surfaces"; import { formatToolCall, @@ -27,17 +28,6 @@ interface SuggestedToolAction { reason: string; } -function formatInlineCode(value: string): string { - const backtickRuns = value.match(/`+/g) ?? []; - const fenceLength = - backtickRuns.reduce((max, run) => Math.max(max, run.length), 0) + 1; - const fence = "`".repeat(fenceLength); - const needsPadding = value.startsWith("`") || value.endsWith("`"); - return needsPadding - ? `${fence} ${value} ${fence}` - : `${fence}${value}${fence}`; -} - /** Builds transcript actions that match the session's direct or catalog surface. */ export function getAIConversationSuggestedActions({ organizationSlug, @@ -132,7 +122,7 @@ export function formatAIConversationActionInstructions({ return actions.map( (action) => - `Use the Sentry tool ${formatInlineCode( + `Use the Sentry tool ${inlineCode( formatToolCall({ toolName: action.toolName, arguments: action.arguments, diff --git a/packages/mcp-core/src/tools/support/search-events/formatters.ts b/packages/mcp-core/src/tools/support/search-events/formatters.ts index 1ffd2a53c..469ee06cb 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1,3 +1,4 @@ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { SentryApiService } from "../../../api-client"; import type { EventsTimeSeriesResponse, @@ -36,17 +37,6 @@ export interface ExecutedSearch { timeRange?: SearchTimeRange; } -function formatInlineCode(value: string): string { - const backtickRuns = value.match(/`+/g) ?? []; - const fenceLength = - backtickRuns.reduce((max, run) => Math.max(max, run.length), 0) + 1; - const fence = "`".repeat(fenceLength); - const needsPadding = value.startsWith("`") || value.endsWith("`"); - return needsPadding - ? `${fence} ${value} ${fence}` - : `${fence}${value}${fence}`; -} - function formatExecutedTimeRange(timeRange?: SearchTimeRange): string { if (!timeRange) { return "Last 14d"; @@ -69,20 +59,20 @@ export function formatExecutedSearch(executedSearch?: ExecutedSearch): string { executedSearch.fields === undefined ? undefined : executedSearch.fields.length > 0 - ? executedSearch.fields.map(formatInlineCode).join(", ") + ? executedSearch.fields.map(inlineCode).join(", ") : "(none)"; const lines = [ "## Executed Search", - `- Dataset: ${formatInlineCode(executedSearch.dataset)}`, - `- Query: ${formatInlineCode(executedSearch.query || "(empty)")}`, + `- Dataset: ${inlineCode(executedSearch.dataset)}`, + `- Query: ${inlineCode(executedSearch.query || "(empty)")}`, ]; if (fields !== undefined) { lines.push(`- Fields: ${fields}`); } if (executedSearch.sort) { - lines.push(`- Sort: ${formatInlineCode(executedSearch.sort)}`); + lines.push(`- Sort: ${inlineCode(executedSearch.sort)}`); } lines.push( `- Time range: ${formatExecutedTimeRange(executedSearch.timeRange)}`, diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 7239655d3..d8b21d925 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -157,6 +157,18 @@ runtime cannot read, such as zstd on older Node, so the caller treats it as a miss. It is a separate entry point so the policy above stays free of Node APIs. +## Markdown + +`markdown` builds Markdown for untrusted values. Pick the helper by what the +value is: `inlineCode` for identifiers and short values, `codeBlock` for +multi-line data, and `text` for values that should read as prose. Each keeps +the value inside the line or block it is placed in, so the value cannot add +headings, list items, links, or other structure. `inlineCode` and `codeBlock` +need no escaping; `text` escapes with +[`mdast-util-to-markdown`](https://github.com/syntax-tree/mdast-util-to-markdown) +and its GFM extension. The property tests parse each helper's output with +micromark and check that any input round-trips as the same literal value. + ## Resource identity The `resource-identity` types describe the identifiers shared by organization, diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 2465f3871..e2fb83b1c 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -74,6 +74,10 @@ "./api-client/*": { "types": "./src/api-client/*.ts", "default": "./src/api-client/*.ts" + }, + "./markdown": { + "types": "./src/markdown.ts", + "default": "./src/markdown.ts" } }, "scripts": { @@ -85,11 +89,15 @@ "@sentry/api": "catalog:", "@sentry/search-syntax": "catalog:", "@standard-schema/spec": "1.1.0", - "http-cache-semantics": "^4.2.0" + "http-cache-semantics": "^4.2.0", + "mdast-util-gfm": "catalog:", + "mdast-util-to-markdown": "catalog:" }, "devDependencies": { "@types/http-cache-semantics": "^4.2.0", "fast-check": "^4.8.0", + "mdast-util-from-markdown": "catalog:", + "micromark-extension-gfm": "catalog:", "typescript": "catalog:", "valibot": "^1.4.2", "vitest": "catalog:", diff --git a/packages/toolkit-core/src/markdown.property.test.ts b/packages/toolkit-core/src/markdown.property.test.ts new file mode 100644 index 000000000..0f512e151 --- /dev/null +++ b/packages/toolkit-core/src/markdown.property.test.ts @@ -0,0 +1,148 @@ +/** + * Property-Based Tests for Markdown builders + * + * Parses each helper's output with micromark (CommonMark + GFM) and checks + * that any input comes back as the same literal value, inside the construct + * it was placed in, with no other Markdown structure. + */ + +import { assert as fcAssert, constantFrom, property, string } from "fast-check"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { describe, expect, test } from "vitest"; +import { codeBlock, inlineCode, text } from "./markdown.js"; + +const NUM_RUNS = 500; + +/** Characters with meaning in CommonMark or GFM, plus line breaks and spacing. */ +const markdownArb = string({ + unit: constantFrom( + ..."\\`*_~[]()<>!#&;|:@.-+=/\"'{}^".split(""), + "a", + "1", + "x", + "www.", + "http://", + "&", + " ", + " ", + " ", + "\t", + "\n", + "\r\n", + "\r", + ), + maxLength: 40, +}); + +function parse(markdown: string) { + return fromMarkdown(markdown, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); +} + +type Root = ReturnType; + +/** Returns the first list item's paragraph children after the `**k**` label. */ +function firstItemAfterLabel(root: Root) { + expect(root.children).toHaveLength(1); + const list = root.children[0]; + if (list?.type !== "list") + throw new Error(`expected list, got ${list?.type}`); + expect(list.children).toHaveLength(2); + const paragraph = list.children[0]?.children[0]; + if (paragraph?.type !== "paragraph") { + throw new Error(`expected paragraph, got ${paragraph?.type}`); + } + const [label, ...rest] = paragraph.children; + expect(label).toMatchObject({ + type: "strong", + children: [{ type: "text", value: "k" }], + }); + return rest; +} + +const singleLine = (value: string) => value.replace(/\r\n?|\n/g, " "); + +type Phrasing = ReturnType[number]; + +/** + * Concatenates the literal text of `nodes`, failing on any Markdown structure. + * GFM literal autolinks are allowed: remark links `www.` domains even after + * escaping, and the link text is still the original text. + */ +function literalText(nodes: Phrasing[]): string { + return nodes + .map((node) => { + if (node.type === "text") return node.value; + if ( + node.type === "link" && + node.children.every((c) => c.type === "text") + ) { + return literalText(node.children); + } + throw new Error(`unexpected ${node.type} node`); + }) + .join(""); +} + +describe("text", () => { + test.each([ + ["mid-line", (v: string) => `- **k**: ${v} end\n- next\n`, " end"], + ["end of line", (v: string) => `- **k**: ${v}\n- next\n`, ""], + ])("round-trips any value as plain text (%s)", (_, render, suffix) => { + fcAssert( + // An empty value leaves the label's trailing space at the end of the line. + property(markdownArb.filter(Boolean), (value) => { + const nodes = firstItemAfterLabel(parse(render(text(value)))); + expect(literalText(nodes)).toBe(`: ${singleLine(value)}${suffix}`); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("inlineCode", () => { + test("round-trips any non-empty value as one code span", () => { + fcAssert( + property( + markdownArb.filter((value) => value.length > 0), + (value) => { + const nodes = firstItemAfterLabel( + parse(`- **k**: ${inlineCode(value)} end\n- next\n`), + ); + expect(nodes).toEqual([ + expect.objectContaining({ type: "text", value: ": " }), + expect.objectContaining({ + type: "inlineCode", + value: singleLine(value), + }), + expect.objectContaining({ type: "text", value: " end" }), + ]); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("codeBlock", () => { + test("round-trips any value as one code block", () => { + fcAssert( + property(markdownArb, (value) => { + const root = parse(`${codeBlock(value, "text")}\n\nafter\n`); + expect(root.children).toEqual([ + expect.objectContaining({ + type: "code", + lang: "text", + value: value.replace(/\r\n?/g, "\n"), + }), + expect.objectContaining({ type: "paragraph" }), + ]); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); diff --git a/packages/toolkit-core/src/markdown.test.ts b/packages/toolkit-core/src/markdown.test.ts new file mode 100644 index 000000000..53c41ecb6 --- /dev/null +++ b/packages/toolkit-core/src/markdown.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { codeBlock, inlineCode, text } from "./markdown.js"; + +describe("inlineCode", () => { + test("wraps a value without escaping Markdown syntax", () => { + expect(inlineCode("