From f635ff44dc66348cf560f55c45709a68eeffc5f8 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:54:02 +0000 Subject: [PATCH] fix(cli): sanitize project argument newlines --- packages/cli/src/lib/arg-parsing.ts | 33 ++++++++++++++----- packages/cli/test/lib/arg-parsing.test.ts | 39 +++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index a0ae144b5..0fa732f39 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -153,6 +153,26 @@ const ISSUE_SHORT_ID_MULTI_SEGMENT_PARTS = 3; /** Splits a string into lines on LF or CRLF boundaries. */ const LINE_SPLIT_PATTERN = /\r?\n/; +/** + * Return the first non-blank line of `arg`, trimmed, or `""` if every line is + * blank. + * + * A bare `.trim()` only strips leading/trailing whitespace, so multi-line input + * (command substitution that captured extra output, a value with an appended + * note, or several newline-separated values — common from AI agents) keeps an + * internal newline that later fails `validateResourceId` with a cryptic + * "contains a newline" error (CLI-1G1, CLI-1RA). Slugs and identifiers are + * always single-line tokens, so the first non-blank line is the intended value. + */ +function firstNonBlankLine(arg: string): string { + return ( + arg + .split(LINE_SPLIT_PATTERN) + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? "" + ); +} + /** Splits a string on any run of whitespace. */ const WHITESPACE_SPLIT_PATTERN = /\s+/; @@ -900,12 +920,13 @@ export function parseOrgProjectArg( arg: string | undefined, options: { multi?: boolean } = {}, ): ParsedOrgProject { - if (!arg || arg.trim() === "") { + // Keep only the first non-blank line: agents often pass targets with + // appended output or notes after a newline (CLI-1RA). + const trimmed = arg ? firstNonBlankLine(arg) : ""; + if (!trimmed) { return { type: "auto-detect" }; } - const trimmed = arg.trim(); - // URL detection — extract org/project from Sentry web URLs const urlParsed = parseSentryUrl(trimmed); if (urlParsed) { @@ -1412,11 +1433,7 @@ export function parseIssueArg(arg: string): ParsedIssueArg { // lines would produce garbage, so we keep only the first line. // Splitting on `\n` (a control char) never breaks project display names with // spaces (#1116), since those are rejected as control chars anyway. - const input = - arg - .split(LINE_SPLIT_PATTERN) - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? ""; + const input = firstNonBlankLine(arg); if (!input) { throw new ValidationError( diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts index c47e50fa3..db8ba6b3b 100644 --- a/packages/cli/test/lib/arg-parsing.test.ts +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -1517,6 +1517,45 @@ describe("parseOrgProjectArg: injection hardening", () => { }); }); +// Agents pass targets with appended output/notes after a newline. A bare +// trim() left the internal newline, which threw "Invalid project slug: +// contains a newline" (CLI-1RA). Targets are single-line, so keep line one. +describe("parseOrgProjectArg: multi-line input (CLI-1RA)", () => { + test("keeps the first line of an org/project target", () => { + expect(parseOrgProjectArg("sentry/cli\nextra")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("keeps the first line of a bare project target", () => { + expect(parseOrgProjectArg("cli\nsome note")).toEqual({ + type: "project-search", + projectSlug: "cli", + }); + }); + + test("skips leading blank lines and handles CRLF", () => { + expect(parseOrgProjectArg("\r\n \r\nsentry/cli\r\nextra\r\n")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("keeps the first line of an org-all target", () => { + expect(parseOrgProjectArg("sentry/\nextra")).toEqual({ + type: "org-all", + org: "sentry", + }); + }); + + test("all-blank multi-line input auto-detects", () => { + expect(parseOrgProjectArg(" \n \r\n ")).toEqual({ type: "auto-detect" }); + }); +}); + describe("parseIssueArg: injection hardening", () => { test("rejects query injection in issue arg", () => { expect(() => parseIssueArg("CLI-G?query=foo")).toThrow(ValidationError);