From 09767b0053286c22604ad7bf3d79b2e87213aecc Mon Sep 17 00:00:00 2001 From: Raees Iqbal <10067728+RaeesBhatti@users.noreply.github.com> Date: Thu, 28 May 2026 12:02:52 -0700 Subject: [PATCH 1/2] feat(auth): add --read-only flag to auth login Adds an opt-in flag on `sentry auth login` that requests only the `*:read` subset of OAUTH_SCOPES (project, org, event, member, team). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state. Refs #1031 --- .../sentry-cli/skills/sentry-cli/references/auth.md | 1 + src/commands/auth/login.ts | 9 +++++++++ src/lib/interactive-login.ts | 6 +++++- src/lib/oauth.ts | 13 +++++++++---- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/plugins/sentry-cli/skills/sentry-cli/references/auth.md index 1b06207f7..b65cca79a 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -20,6 +20,7 @@ Authenticate with Sentry - `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` - `--force - Re-authenticate without prompting` - `--url - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).` +- `--read-only - Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.` **Examples:** diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 03d4830fe..b4990efe6 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -77,6 +77,7 @@ type LoginFlags = { readonly timeout: number; readonly force: boolean; readonly url?: string; + readonly "read-only": boolean; }; /** @@ -356,6 +357,13 @@ export const loginCommand = buildCommand({ "Required for self-hosted; defaults to SaaS (https://sentry.io).", optional: true, }, + "read-only": { + kind: "boolean", + brief: + "Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). " + + "Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.", + default: false, + }, }, }, output: { human: formatLoginResult }, @@ -435,6 +443,7 @@ export const loginCommand = buildCommand({ // OAuth device flow (host scope recorded via completeOAuthFlow → setAuthToken) const result = await runInteractiveLogin({ timeout: flags.timeout * 1000, + readOnly: flags["read-only"], }); if (result) { diff --git a/src/lib/interactive-login.ts b/src/lib/interactive-login.ts index ecc95065e..f65b33205 100644 --- a/src/lib/interactive-login.ts +++ b/src/lib/interactive-login.ts @@ -52,6 +52,8 @@ export function toLoginUser(user: { export type InteractiveLoginOptions = { /** Timeout for OAuth flow in milliseconds (default: 900000 = 15 minutes) */ timeout?: number; + /** Request only read-only OAuth scopes (default: false) */ + readOnly?: boolean; }; /** @@ -113,6 +115,7 @@ export async function runInteractiveLogin( options?: InteractiveLoginOptions ): Promise { const timeout = options?.timeout ?? 900_000; // 15 minutes default + const readOnly = options?.readOnly ?? false; log.info("Starting authentication..."); @@ -165,7 +168,8 @@ export async function runInteractiveLogin( } }, }, - timeout + timeout, + readOnly ); // Stop the spinner diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 9ede7ffad..9c4a98a69 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -90,6 +90,9 @@ export const OAUTH_SCOPES: readonly string[] = [ /** Space-joined scope string for OAuth requests */ const SCOPES = OAUTH_SCOPES.join(" "); +const SCOPES_READ_ONLY = OAUTH_SCOPES.filter((s) => s.endsWith(":read")).join( + " " +); type DeviceFlowCallbacks = { onUserCode: ( @@ -171,8 +174,9 @@ function assertRefreshHostTrusted(): void { } /** Request a device code from Sentry's device authorization endpoint */ -function requestDeviceCode() { +function requestDeviceCode(readOnly = false) { const clientId = getClientId(); + const scope = readOnly ? SCOPES_READ_ONLY : SCOPES; return withHttpSpan("POST", "/oauth/device/code/", async () => { const response = await fetchWithConnectionError( `${getSentryUrl()}/oauth/device/code/`, @@ -181,7 +185,7 @@ function requestDeviceCode() { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, - scope: SCOPES, + scope, }), } ); @@ -310,7 +314,8 @@ async function attemptPoll(deviceCode: string): Promise { */ export async function performDeviceFlow( callbacks: DeviceFlowCallbacks, - timeout = 600_000 // 10 minutes default (matches Sentry's expires_in) + timeout = 600_000, // 10 minutes default (matches Sentry's expires_in) + readOnly = false ): Promise { // Step 1: Request device code const { @@ -320,7 +325,7 @@ export async function performDeviceFlow( verification_uri_complete, interval, expires_in, - } = await requestDeviceCode(); + } = await requestDeviceCode(readOnly); // Notify caller of the user code await callbacks.onUserCode( From d7e902de30f8cd9b6f6ee0c14af6363249c8c11c Mon Sep 17 00:00:00 2001 From: Raees Iqbal <10067728+RaeesBhatti@users.noreply.github.com> Date: Thu, 28 May 2026 13:22:49 -0700 Subject: [PATCH 2/2] fix(auth): refuse --read-only with --token OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing API token's permissions. Silently dropping --read-only on the --token path would give a false sense of safety to AI-agent and CI use cases. Refuse the combination with a ValidationError that points at the two correct paths. Reported by Cursor Bugbot on #1032. --- src/commands/auth/login.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index b4990efe6..c720131a9 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -368,6 +368,23 @@ export const loginCommand = buildCommand({ }, output: { human: formatLoginResult }, async *func(this: SentryContext, flags: LoginFlags) { + // --read-only only applies to the OAuth device flow — OAuth scope is fixed + // when the token is issued, so the CLI cannot narrow an existing token's + // permissions. Refuse the combination rather than silently dropping the flag. + if (flags.token && flags["read-only"]) { + throw new ValidationError( + [ + "--read-only cannot be used with --token — OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing token's permissions.", + "", + "To use OAuth read-only:", + " sentry auth login --read-only", + "", + "Or create a read-only User Auth Token in Sentry (Account → Auth Tokens) and pass it without --read-only.", + ].join("\n"), + "read-only" + ); + } + // Apply --url first so the device flow / token refresh target the // requested instance. Default URL persistence is deferred until login // succeeds — see persistLoginUrlAsDefault calls below.