Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions packages/cli/src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+/;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/lib/arg-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading