diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index fef5c8913..266e7c607 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -193,6 +193,7 @@ import type { SentryContext } from "../../context.js"; import { CommandOutput } from "../../lib/formatters/output.js"; export const myCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Short description", fullDescription: "Detailed description", @@ -221,6 +222,8 @@ export const myCommand = buildCommand({ - The wrapper auto-injects `--json` and `--fields` flags. Do NOT add your own `json` flag. - A `jsonTransform` replaces the default `--fields` filtering, so it must apply its `fields` argument (`filterFieldsIfSet` from `lib/formatters/json.ts`, or per item for list envelopes). The `sentry-cli/json-transform-uses-fields` lint rule rejects one that never reads it ([#1808](https://github.com/getsentry/toolkit/issues/1808)). Drop a transform that only returns its input; the default path already filters. - Do NOT use `stdout.write()` or `if (flags.json)` branching — the wrapper handles it. +- Every command declares an `exposure`, where agents may run it through the MCP: `exposed`, `stdio-only` (it reads or writes local files, uses the `cwd` or git, reads the local credential store, or needs a terminal), or `hidden` (session management, CLI maintenance, `local`, `monitor run`). The builder types require it. When unsure, use `stdio-only`. +- The permission class (read, write, destructive) comes from the Sentry API requests `generate:command-api` finds, and `destructive` for destructive commands. Declare `permission` only when `check:command-access` asks for it: requests outside the Sentry API (`event send`, `docs`), source the analysis can't follow, or `method` for `sentry api`. The check rejects any other declaration (`lib/command-access.ts`, [#1880](https://github.com/getsentry/toolkit/issues/1880)). ### Command File Structure diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index eb0c62afa..8a29e6827 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -121,6 +121,14 @@ parallel list of operation names or command vocabulary. Build diagnostics report raw or unpublished calls that the generated catalog cannot identify, so the relationships describe discovered usage rather than exhaustive coverage. +The same step writes `src/generated/command-api-access.json`: whether each +command's Sentry API requests only read (GET, HEAD, OPTIONS) or also write. +It counts the requests outside the public catalog that `command-api.json` +leaves out. Introspection combines it with the command's `destructive` flag +and any declared `permission` into the command's permission class, and +`check:command-access` fails when a declaration contradicts the requests or +a command declares no exposure. + Introspection derives canonical command paths, registered aliases, and group membership from the route tree. Groups collect their public commands' generated references, so adding a command requires no separate group mapping. A related API diff --git a/packages/cli/package.json b/packages/cli/package.json index aa25a0d50..570c8ce90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -95,7 +95,8 @@ "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", "check:destructive": "pnpm tsx script/check-destructive-commands.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive" + "check:command-access": "pnpm tsx script/check-command-access.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive && pnpm run check:command-access" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/script/check-command-access.ts b/packages/cli/script/check-command-access.ts new file mode 100644 index 000000000..3e491a6ef --- /dev/null +++ b/packages/cli/script/check-command-access.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env tsx +/** + * Verify every command declares an exposure and that its permission class + * agrees with the requests it sends (`script/lib/command-access.ts`). Uses the + * same source analysis as `generate:command-api`. + */ + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Command, RouteMap } from "../src/lib/introspect.js"; +import { + allCommands, + type CommandRequests, + commandAccessProblems, +} from "./lib/command-access.js"; +import { analyzeCliSources } from "./lib/source-api-usage.js"; + +const { analysis } = await analyzeCliSources(); + +// A command re-exported under another name has one entry per export. +const requests = new Map(); +for (const { file, name } of analysis.commands) { + const usage = analysis.exports.get(file)?.get(name); + const module = await import(pathToFileURL(resolve(file)).href); + const command = module[name] as Command | undefined; + if (!(usage && command)) continue; + const previous = requests.get(command); + requests.set(command, { + methods: [...(previous?.methods ?? []), ...usage.methods], + apiMethods: [...(previous?.apiMethods ?? []), ...usage.apiMethods], + }); +} + +const { routes } = await import("../src/app.js"); +const commands = allCommands(routes as unknown as RouteMap); +const problems = [...commands].flatMap(([command, path]) => + commandAccessProblems(command, requests.get(command)).map( + (problem) => `${path} ${problem}`, + ), +); + +if (problems.length === 0) { + console.log( + `✓ All ${commands.size} commands declare an exposure and a permission class that matches their requests`, + ); +} else { + console.error( + `✗ ${problems.length} command access problem(s):\n\n${problems.map((problem) => ` ${problem}`).join("\n")}`, + ); + console.error( + "\n See script/lib/command-access.ts for when a command declares `permission`.", + ); + process.exitCode = 1; +} diff --git a/packages/cli/script/check-destructive-commands.ts b/packages/cli/script/check-destructive-commands.ts index a094324eb..b0688eac3 100644 --- a/packages/cli/script/check-destructive-commands.ts +++ b/packages/cli/script/check-destructive-commands.ts @@ -5,36 +5,14 @@ * raw requests that cannot be linked to a public API operation. */ -import { readFile } from "node:fs/promises"; -import { glob } from "tinyglobby"; -import { getAllEndpoints } from "../src/lib/api-schema.js"; -import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; +import { analyzeCliSources } from "./lib/source-api-usage.js"; -const files = await glob("src/**/*.{ts,tsx}", { - ignore: ["src/generated/**", "**/*.test.ts"], -}); -const sources = new Map( - await Promise.all( - files.map(async (file) => [file, await readFile(file, "utf-8")] as const), - ), -); -const operations = getAllEndpoints().flatMap((endpoint) => - endpoint.fn - ? [ - { - functionName: endpoint.fn, - method: endpoint.method, - path: endpoint.path, - }, - ] - : [], -); +const { operations, analysis } = await analyzeCliSources(); if (!operations.some(({ method }) => method === "DELETE")) { throw new Error( "API operation catalog contains no DELETE operations; regenerate the API schema before checking command safety.", ); } -const analysis = analyzeSourceApiUsage(sources, operations); const deleting = analysis.commands.filter( ({ file, name }) => file.startsWith("src/commands/") && diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index e58bfb094..bd9d43281 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -36,6 +36,7 @@ const GRAMMAR_OUTPUTS = [ const DOCS_OUTPUTS = [ "src/generated/output-schemas", "src/generated/command-api.json", + "src/generated/command-api-access.json", "src/generated/skill-content.ts", "src/generated/completion-targets.json", "src/generated/top-level-routes.json", diff --git a/packages/cli/script/generate-command-api.ts b/packages/cli/script/generate-command-api.ts index c6c0e8f03..f16411584 100644 --- a/packages/cli/script/generate-command-api.ts +++ b/packages/cli/script/generate-command-api.ts @@ -1,10 +1,15 @@ -/** Generate command/API relationships from source calls and the public route tree. */ +/** + * Generate command/API relationships from source calls and the public route + * tree, and the class of each command's Sentry API requests + * (`lib/command-access.ts`). + */ import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { routes } from "../src/app.js"; import type { ApiReference } from "../src/lib/api-schema.js"; +import { type ApiAccess, apiAccess } from "../src/lib/command-access.js"; import { isCommand, publicTargetPaths, @@ -14,6 +19,7 @@ import { import { commandApiAnalysis } from "./lib/command-api-analysis.js"; const OUTPUT_PATH = "src/generated/command-api.json"; +const ACCESS_OUTPUT_PATH = "src/generated/command-api-access.json"; /** Write which API operations each public command calls. */ export async function emit(): Promise { @@ -26,6 +32,7 @@ export async function emit(): Promise { string, { commands: Set; reason: string } >(); + const apiMethods = new Map(); for (const { file, name } of analysis.commands) { const module = await import(pathToFileURL(resolve(file)).href); @@ -36,6 +43,10 @@ export async function emit(): Promise { const command = `sentry ${path.join(" ")}`; const usage = analysis.exports.get(file)?.get(name); if (!usage) continue; + apiMethods.set(command, [ + ...(apiMethods.get(command) ?? []), + ...usage.apiMethods, + ]); const entries = (references[command] ??= []); for (const operation of usage.operations) { const context = Object.entries(operation.query ?? {}) @@ -95,6 +106,17 @@ export async function emit(): Promise { ]), ); await writeFile(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`); + const apiAccessByCommand: Record = {}; + for (const [command, methods] of [...apiMethods].sort(([a], [b]) => + a.localeCompare(b), + )) { + const value = apiAccess(methods); + if (value) apiAccessByCommand[command] = value; + } + await writeFile( + ACCESS_OUTPUT_PATH, + `${JSON.stringify(apiAccessByCommand, null, 2)}\n`, + ); console.log( `Generated ${OUTPUT_PATH} (${Object.keys(catalog).length} commands; ${unresolved.size} unresolved requests)`, ); diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index 7841e922d..585af2def 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -195,7 +195,8 @@ const TREE_STEPS: readonly Step[] = [ { name: "skill", load: () => import("./generate-skill.js") }, { name: "sdk", load: () => import("./generate-sdk.js") }, // Last: its emit waits for the worker `prepare` starts, and no step reads - // `command-api.json` back (only `sentry schema` does, at runtime). + // `command-api.json` or `command-api-access.json` back (only the CLI does, + // at runtime). { name: "command-api", load: () => import("./generate-command-api.js"), diff --git a/packages/cli/script/lib/command-access.ts b/packages/cli/script/lib/command-access.ts new file mode 100644 index 000000000..a72975706 --- /dev/null +++ b/packages/cli/script/lib/command-access.ts @@ -0,0 +1,142 @@ +/** + * Rules for `check:command-access`: every command declares where agents may + * run it, and its permission class agrees with the requests it sends. + * + * A command's class comes from its Sentry API requests (`apiAccess`), so a + * declaration is accepted only where those can't settle it: + * - it also sends a write method outside the Sentry API (DSN ingest, the docs + * service), and must say whether that writes; + * - the analysis can't follow its source, or can't read a request's method; + * - `method`, for a command without fixed requests whose class each call's + * HTTP method sets (`sentry api`). + * Hidden commands never run for agents, so they need no declaration, but a + * declaration they make is still checked. + */ + +import { + apiAccess, + COMMAND_EXPOSURES, + isWriteMethod, +} from "../../src/lib/command-access.js"; +import { + type Command, + isCommand, + isRouteMap, + type RouteMap, +} from "../../src/lib/introspect.js"; + +/** Requests the source analysis found for one command. */ +export type CommandRequests = { + /** Every method found, including requests outside the Sentry API. */ + methods: readonly string[]; + /** Methods of its Sentry API requests; `undefined` when unknown. */ + apiMethods: readonly (string | undefined)[]; +}; + +/** Every command in the tree, hidden routes included, with its first path. */ +export function allCommands( + routeMap: RouteMap, + prefix: readonly string[] = [], + found = new Map(), +): Map { + for (const entry of routeMap.getAllEntries()) { + const path = [...prefix, entry.name.original]; + if (isRouteMap(entry.target)) { + allCommands(entry.target, path, found); + } else if (isCommand(entry.target) && !found.has(entry.target)) { + found.set(entry.target, `sentry ${path.join(" ")}`); + } + } + return found; +} + +/** + * Problems with one command's exposure and permission. `requests` is + * undefined when the analysis found no source export for the command. + */ +export function commandAccessProblems( + command: Command, + requests: CommandRequests | undefined, +): string[] { + const problems: string[] = []; + const exposure = command.__exposure; + if (!(exposure && COMMAND_EXPOSURES.includes(exposure))) { + problems.push( + `declares no exposure (${COMMAND_EXPOSURES.join(", ")}); pass \`exposure\` to its builder`, + ); + } + const declared = command.__permission; + if (command.__destructive) { + if (declared) { + problems.push( + `is destructive, which sets its class; drop \`permission: "${declared}"\``, + ); + } + return problems; + } + const required = exposure !== "hidden"; + if (!requests) { + if (!declared && required) { + problems.push( + "has no source export the API analysis can follow; declare `permission`", + ); + } + return problems; + } + + const api = apiAccess(requests.apiMethods); + const apiWrites = unique( + requests.apiMethods.filter( + (method): method is string => + method !== undefined && isWriteMethod(method), + ), + ); + const otherWrites = unique( + requests.methods.filter( + (method) => + isWriteMethod(method) && !requests.apiMethods.includes(method), + ), + ); + if (api === "write") { + if (declared) { + problems.push( + declared === "write" + ? 'declares `permission: "write"`, which its API requests already give; drop it' + : `declares \`permission: "${declared}"\`, but it sends ${apiWrites.join("/")} Sentry API requests`, + ); + } + } else if (api === "unknown") { + if (!declared && required) { + problems.push( + "sends a Sentry API request whose method the analysis can't read; declare `permission`", + ); + } + } else if (declared === "method") { + if (api === "read") { + problems.push( + 'declares `permission: "method"`, but its Sentry API requests are read-only', + ); + } else if (otherWrites.length > 0) { + problems.push( + `declares \`permission: "method"\`, but it sends ${otherWrites.join("/")} requests outside the Sentry API; declare \`permission: "read"\` or \`"write"\``, + ); + } + } else if (otherWrites.length > 0) { + if (!declared && required) { + problems.push( + `sends ${otherWrites.join("/")} requests outside the Sentry API; declare \`permission: "read"\` or \`"write"\``, + ); + } + } else if (declared) { + problems.push( + declared === "write" + ? `declares \`permission: "write"\`, but ${api === "read" ? "its Sentry API requests are read-only" : "it sends no request"}` + : 'declares `permission: "read"`, which its requests already give; drop it', + ); + } + return problems; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/cli/script/lib/route-tree-stub-files.mjs b/packages/cli/script/lib/route-tree-stub-files.mjs index f5264c714..e346a0d63 100644 --- a/packages/cli/script/lib/route-tree-stub-files.mjs +++ b/packages/cli/script/lib/route-tree-stub-files.mjs @@ -10,6 +10,7 @@ /** @type {ReadonlyArray} */ export const ROUTE_TREE_STUBS = [ ["src/generated/command-api.json", "{}\n"], + ["src/generated/command-api-access.json", "{}\n"], ["src/generated/top-level-routes.json", "{}\n"], [ "src/generated/skill-content.ts", diff --git a/packages/cli/script/lib/source-api-usage.ts b/packages/cli/script/lib/source-api-usage.ts index cd2687cc6..a9f6dee34 100644 --- a/packages/cli/script/lib/source-api-usage.ts +++ b/packages/cli/script/lib/source-api-usage.ts @@ -1,13 +1,16 @@ /** CLI conventions for the SDK's build-time API usage analyzer. */ +import { readFile } from "node:fs/promises"; import { analyzeSourceApiUsage as analyze, type SourceApiOperation, } from "@sentry/api/source-analysis"; +import { glob } from "tinyglobby"; +import type { ApiEndpoint } from "../../src/lib/api-schema.js"; export type { SourceApiOperation } from "@sentry/api/source-analysis"; -/** Discover command API usage and DELETE declarations without executing commands. */ +/** Discover command API usage, HTTP methods, and DELETE declarations without executing commands. */ export function analyzeSourceApiUsage( sources: ReadonlyMap, operations: readonly SourceApiOperation[], @@ -54,6 +57,13 @@ export function analyzeSourceApiUsage( operations: usage.operations, unresolvedRequests: usage.unresolvedRequests, deletes: usage.methods.includes("DELETE"), + /** Every request method found, including requests outside the Sentry API. */ + methods: usage.methods, + /** Methods of the Sentry API requests; `undefined` when unknown. */ + apiMethods: [ + ...usage.operations.map(({ method }) => method), + ...usage.unresolvedRequests.map(({ method }) => method), + ], }, ]), ), @@ -68,3 +78,37 @@ export function analyzeSourceApiUsage( })), }; } + +/** + * Analyze the CLI's own sources (`src/`, without tests or generated files) + * against the API catalog. + * + * It reads `api-schema.json` from disk rather than importing it: + * `command-api-analysis-worker.ts` imports this module before + * `script/generate.ts` has written the schema on a fresh checkout. + */ +export async function analyzeCliSources() { + const files = await glob("src/**/*.{ts,tsx}", { + ignore: ["src/generated/**", "**/*.test.ts"], + }); + const sources = new Map( + await Promise.all( + files.map(async (file) => [file, await readFile(file, "utf-8")] as const), + ), + ); + const endpoints = JSON.parse( + await readFile("src/generated/api-schema.json", "utf-8"), + ) as ApiEndpoint[]; + const operations = endpoints.flatMap((endpoint) => + endpoint.fn + ? [ + { + functionName: endpoint.fn, + method: endpoint.method, + path: endpoint.path, + }, + ] + : [], + ); + return { operations, analysis: analyzeSourceApiUsage(sources, operations) }; +} diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index a77ac7aca..7d6e6b140 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -126,6 +126,7 @@ function jsonTransform( } export const listCommand = buildListCommand("agent-conversation", { + exposure: "exposed", docs: { brief: "List recent agent conversations", fullDescription: diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index 1d0317091..efaa74d05 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -105,6 +105,7 @@ export function parseConversationArguments( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View an agent conversation transcript", customUsage: [USAGE], diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index d6f33c4a4..27518bbc6 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -102,6 +102,7 @@ async function resolveCreateTarget( } export const createCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create an issue alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/issues/delete.ts b/packages/cli/src/commands/alert/issues/delete.ts index 1e91e4906..215cd2fb8 100644 --- a/packages/cli/src/commands/alert/issues/delete.ts +++ b/packages/cli/src/commands/alert/issues/delete.ts @@ -40,6 +40,7 @@ function formatDeleted(r: IssueAlertDeleteOutput): string { } export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Delete an issue alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index 5766ea3df..e6d5a6725 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -192,6 +192,7 @@ function formatEdited(r: IssueAlertEditOutput): string { } export const editCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Edit an issue alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index dab6c4add..9a14826bd 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -494,6 +494,7 @@ const jsonTransformIssueAlertList = jsonTransformListResult; // Command export const listCommand = buildListCommand("alert issues", { + exposure: "exposed", docs: { brief: "List issue alert rules", fullDescription: diff --git a/packages/cli/src/commands/alert/issues/view.ts b/packages/cli/src/commands/alert/issues/view.ts index eea783331..b42cafc6f 100644 --- a/packages/cli/src/commands/alert/issues/view.ts +++ b/packages/cli/src/commands/alert/issues/view.ts @@ -41,6 +41,7 @@ function formatIssueAlertView(data: IssueRuleResolution): string { } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View an issue alert rule", fullDescription: "View a single issue alert rule by ID or name.", diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index 15aa155e9..70d76b8d7 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -84,6 +84,7 @@ function resolveMetricCreateOrg( } export const createCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create a metric alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 6b39ca3f7..c594ba8f6 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -38,6 +38,7 @@ function formatDeleted(r: MetricAlertDeleteOutput): string { } export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Delete a metric alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 0c43ba548..ff2017cdf 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -183,6 +183,7 @@ function formatEdited(r: MetricAlertEditOutput): string { } export const editCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Edit a metric alert rule", fullDescription: diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index a71f8c430..d2d6f42d6 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -500,6 +500,7 @@ const jsonTransformMetricAlertList = jsonTransformListResult; // Command export const listCommand = buildListCommand("alert metrics", { + exposure: "exposed", docs: { brief: "List metric alert rules", fullDescription: diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 78915aeb2..dc0439134 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -27,6 +27,7 @@ type ViewFlags = { }; export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View a metric alert rule", fullDescription: diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index f8ab967fb..b1c4a1118 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -1565,6 +1565,8 @@ export function resolveBinaryTtyOutput( } export const apiCommand = buildCommand({ + exposure: "exposed", + permission: "method", output: { human: formatApiResponse, jsonTransform: formatApiResponseJson }, docs: { brief: topLevelBrief("api"), diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index f1df2164a..c6dc88183 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -189,6 +189,7 @@ function pageHint( } export const listCommand = buildListCommand("attribute", { + exposure: "exposed", docs: { brief: "List attribute keys for spans, logs, or metrics", fullDescription: diff --git a/packages/cli/src/commands/auth/bootstrap.ts b/packages/cli/src/commands/auth/bootstrap.ts index b7b97d202..38c79fb58 100644 --- a/packages/cli/src/commands/auth/bootstrap.ts +++ b/packages/cli/src/commands/auth/bootstrap.ts @@ -12,6 +12,9 @@ import { applyLoginUrl, formatLoginResult, parseLoginUrl } from "./login.js"; /** Authenticate without opening a browser using a private, one-use setup code. */ export const bootstrapCommand = buildCommand({ + exposure: "hidden", + // Redeems a one-use setup code and stores the session + permission: "write", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/auth/default.ts b/packages/cli/src/commands/auth/default.ts index c76ffcd46..f78066559 100644 --- a/packages/cli/src/commands/auth/default.ts +++ b/packages/cli/src/commands/auth/default.ts @@ -109,6 +109,7 @@ function formatAuthDefaultOutput(data: LoginResult | AuthStatusData): string { * an explicit subcommand. Renders through this command's single pipeline. */ export const authDefaultCommand = buildCommand({ + exposure: "hidden", auth: false, // Login must run even with a poisoned .sentryclirc URL. Status is also // reached through this path when already authenticated — matching the diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 77df081e4..42fcfb341 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -22,6 +22,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { AccountSummarySchema } from "../../generated/output-schemas/account.js"; export const listCommand = buildCommand({ + exposure: "stdio-only", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 66ce49633..3d64a661d 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -480,6 +480,7 @@ function hasUsablePreviousAccount( } export const loginCommand = buildCommand({ + exposure: "hidden", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 82f98dbab..d75350649 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -85,6 +85,7 @@ function assertNotEnvOnly(): void { } export const logoutCommand = buildCommand({ + exposure: "hidden", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index 01c2dbc99..af08f1606 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -87,6 +87,7 @@ function formatRefreshResult(data: RefreshOutput): string { } export const refreshCommand = buildCommand({ + exposure: "hidden", auth: false, docs: { brief: "Refresh your OAuth access token", diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index a714a6ca8..64705d667 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -194,6 +194,7 @@ async function collectEnvToken( } export const statusCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "View authentication status", diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 1c37c7793..7d215ce29 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -73,6 +73,7 @@ async function pickAccount( } export const switchCommand = buildCommand({ + exposure: "hidden", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/auth/token.ts b/packages/cli/src/commands/auth/token.ts index 5217e75e7..5c382473b 100644 --- a/packages/cli/src/commands/auth/token.ts +++ b/packages/cli/src/commands/auth/token.ts @@ -12,6 +12,7 @@ import { AuthError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; export const tokenCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Print the stored authentication token", fullDescription: diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 7b5784f59..58ceea9fb 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -51,6 +51,7 @@ function formatWhoami( } export const whoamiCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Show the currently authenticated identity", fullDescription: diff --git a/packages/cli/src/commands/bash-hook.ts b/packages/cli/src/commands/bash-hook.ts index 1c6ad6c6a..299cfcb33 100644 --- a/packages/cli/src/commands/bash-hook.ts +++ b/packages/cli/src/commands/bash-hook.ts @@ -243,6 +243,7 @@ function handleScriptOutput(flags: BashHookFlags): BashHookResult { } export const bashHookCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Print a bash script for shell error reporting", fullDescription: diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts index 423594ff3..0086caf19 100644 --- a/packages/cli/src/commands/build/download.ts +++ b/packages/cli/src/commands/build/download.ts @@ -50,6 +50,7 @@ function formatDownloadResult(data: BuildDownloadResult): string { } export const downloadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Download a build artifact", fullDescription: diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 809bde156..e1ee38499 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -177,6 +177,7 @@ async function uploadOne( } export const uploadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Upload builds to a project", fullDescription: diff --git a/packages/cli/src/commands/cli/completion.ts b/packages/cli/src/commands/cli/completion.ts index b12fe8c60..1350e8046 100644 --- a/packages/cli/src/commands/cli/completion.ts +++ b/packages/cli/src/commands/cli/completion.ts @@ -21,6 +21,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { detectShellType } from "../../lib/shell.js"; export const completionCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "Print the shell completion script", diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 238cbb362..740cadfd1 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -243,6 +243,7 @@ export type DefaultsResult = { const log = logger.withTag("defaults"); export const defaultsCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "View and manage default settings", diff --git a/packages/cli/src/commands/cli/feedback.ts b/packages/cli/src/commands/cli/feedback.ts index 22af1d6f6..8a3dd528e 100644 --- a/packages/cli/src/commands/cli/feedback.ts +++ b/packages/cli/src/commands/cli/feedback.ts @@ -28,6 +28,7 @@ export type FeedbackResult = { }; export const feedbackCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "Send feedback about the CLI", diff --git a/packages/cli/src/commands/cli/fix.ts b/packages/cli/src/commands/cli/fix.ts index 53b9f0986..d118fda51 100644 --- a/packages/cli/src/commands/cli/fix.ts +++ b/packages/cli/src/commands/cli/fix.ts @@ -658,6 +658,7 @@ function safeHandleSchemaIssues( } export const fixCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "Diagnose and repair CLI database issues", diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 50ee7a35c..addba2c52 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -260,6 +260,7 @@ function parseImportUrl(raw: string): string { } export const importCommand = buildCommand({ + exposure: "stdio-only", auth: false, skipRcUrlCheck: true, docs: { diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index cac464876..bfaf7f017 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -621,6 +621,7 @@ async function runConfigurationSteps(opts: ConfigStepOptions) { } export const setupCommand = buildCommand({ + exposure: "hidden", auth: false, docs: { brief: "Configure shell integration", diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index 6fd354665..200e80b20 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -428,6 +428,7 @@ function formatUninstallHuman(result: UninstallResult): string { } export const uninstallCommand = buildDeleteCommand({ + exposure: "hidden", docs: { brief: "Uninstall Sentry CLI", fullDescription: diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 83048238f..4df0581d9 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -1136,6 +1136,7 @@ async function buildCheckResultWithChangelog(opts: { } export const upgradeCommand = buildCommand({ + exposure: "hidden", auth: false, docs: { brief: "Update the Sentry CLI to the latest version", diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 292e783c7..5f7cebeed 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -169,6 +169,7 @@ async function readAndValidateMappings( // Command export const uploadCommand = buildCommand({ + exposure: "stdio-only", auth: true, docs: { brief: "Upload code mappings for stack trace linking", diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index 382c23665..63697221d 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -156,6 +156,7 @@ async function readMappingFile(path: string): Promise { // Command export const uploadCommand = buildCommand({ + exposure: "stdio-only", // Auth is not required for --no-upload (dry-run mode). // The upload path calls resolveOrgAndProject which triggers auth. auth: false, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index a8a96a244..d674fb75b 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -160,6 +160,7 @@ async function resolveDashboardTarget( } export const createCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create a dashboard", fullDescription: diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 71dd3f79f..069e2cae7 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -369,6 +369,7 @@ function buildHint( // Command export const listCommand = buildListCommand("dashboard", { + exposure: "exposed", docs: { brief: "List dashboards", fullDescription: diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 34f68d73a..243662340 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -47,6 +47,7 @@ function formatRestoreHuman(result: RestoreResult): string { } export const restoreCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Restore a dashboard revision", fullDescription: diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index dd54647c9..a89509ef0 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -108,6 +108,7 @@ function jsonTransformRevisions( } export const revisionsCommand = buildCommand({ + exposure: "exposed", docs: { brief: "List dashboard revisions", fullDescription: diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 7acc0b65a..2e0eab880 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -158,6 +158,7 @@ function resolveViewTimeRange( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View a dashboard", fullDescription: diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index dc7784ab9..f32c2a5e6 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -107,6 +107,7 @@ function parseAddPositionalArgs(args: readonly string[]): { } export const addCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Add a widget to a dashboard", fullDescription: diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 41c61dff4..4cd641c9b 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -39,6 +39,7 @@ type DeleteFlags = { export const deleteCommand = buildDeleteCommand( { + exposure: "exposed", docs: { brief: "Delete a widget from a dashboard", fullDescription: diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index b185924ab..2efade08b 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -205,6 +205,7 @@ function buildReplacement( } export const editCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Edit a widget in a dashboard", fullDescription: diff --git a/packages/cli/src/commands/debug-files/bundle-jvm.ts b/packages/cli/src/commands/debug-files/bundle-jvm.ts index cff0cb069..ad21b62aa 100644 --- a/packages/cli/src/commands/debug-files/bundle-jvm.ts +++ b/packages/cli/src/commands/debug-files/bundle-jvm.ts @@ -49,6 +49,7 @@ function formatBundleResult(data: BundleJvmResult): string { } export const bundleJvmCommand = buildCommand({ + exposure: "stdio-only", auth: false, docs: { brief: "Create a JVM source bundle for source context", diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 7e0d32cf4..bcd1ec8b2 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -66,6 +66,7 @@ function formatBundleResult(data: BundleSourcesResult): string { } export const bundleSourcesCommand = buildCommand({ + exposure: "stdio-only", // Local-only: parses + bundles in-process, no API calls. auth: false, docs: { diff --git a/packages/cli/src/commands/debug-files/check.ts b/packages/cli/src/commands/debug-files/check.ts index cc1c7b19a..79c7a5309 100644 --- a/packages/cli/src/commands/debug-files/check.ts +++ b/packages/cli/src/commands/debug-files/check.ts @@ -111,6 +111,7 @@ function isUsable(archive: DifArchiveInfo): boolean { } export const checkCommand = buildCommand({ + exposure: "stdio-only", // Local-only: parses the file in-process, no API calls. auth: false, docs: { diff --git a/packages/cli/src/commands/debug-files/find.ts b/packages/cli/src/commands/debug-files/find.ts index cf020f181..d6cdc3210 100644 --- a/packages/cli/src/commands/debug-files/find.ts +++ b/packages/cli/src/commands/debug-files/find.ts @@ -113,6 +113,7 @@ async function resolveSearchPaths( } export const findCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Locate debug files for given debug identifiers", fullDescription: diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index 118e58e17..e5c83567c 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -97,6 +97,7 @@ function formatPrintSources(data: PrintSourcesResult): string { } export const printSourcesCommand = buildCommand({ + exposure: "stdio-only", // Local-only: parses + enumerates in-process, no API calls. auth: false, docs: { diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index e2e2dbcac..2f90de4ba 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -527,6 +527,7 @@ async function* doUpload( } export const uploadCommand = buildCommand({ + exposure: "stdio-only", // Auth is not required for --no-upload (dry-run). The upload path calls // resolveOrgAndProject which triggers auth resolution. auth: false, diff --git a/packages/cli/src/commands/docs/list.ts b/packages/cli/src/commands/docs/list.ts index 757a5324a..a6cde8049 100644 --- a/packages/cli/src/commands/docs/list.ts +++ b/packages/cli/src/commands/docs/list.ts @@ -34,6 +34,8 @@ function formatListHuman(data: DocsListResponse): string { } export const listCommand = buildCommand({ + exposure: "exposed", + permission: "read", docs: { brief: "Find Sentry documentation pages by keyword", fullDescription: diff --git a/packages/cli/src/commands/docs/query.ts b/packages/cli/src/commands/docs/query.ts index 9d555d62e..b4d6b61f0 100644 --- a/packages/cli/src/commands/docs/query.ts +++ b/packages/cli/src/commands/docs/query.ts @@ -27,6 +27,8 @@ function formatQueryHuman(data: QueryOutput): string { } export const queryCommand = buildCommand({ + exposure: "exposed", + permission: "read", docs: { brief: "Ask a cited question about Sentry documentation", fullDescription: diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index dc04182f7..310220342 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -296,6 +296,7 @@ async function listForResolvedProjects< } export const listCommand = buildListCommand("dsn", { + exposure: "exposed", docs: { brief: "List DSNs", fullDescription: diff --git a/packages/cli/src/commands/event/analytics.ts b/packages/cli/src/commands/event/analytics.ts index f628aecdc..b6f592ecb 100644 --- a/packages/cli/src/commands/event/analytics.ts +++ b/packages/cli/src/commands/event/analytics.ts @@ -39,6 +39,7 @@ type AnalyticsFlags = { }; export const analyticsCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Analytics for events Sentry accepted and dropped", fullDescription: diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index 6205e5eb2..6e8b2b65d 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -74,6 +74,7 @@ function prevPageHint( // --------------------------------------------------------------------------- export const listCommand = buildListCommand("event", { + exposure: "exposed", docs: { brief: "List events for an issue", fullDescription: diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 3d683c406..fd5023002 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -105,6 +105,8 @@ async function buildFilePayload( } export const sendCommand = buildCommand({ + exposure: "stdio-only", + permission: "write", docs: { brief: "Send a Sentry event", fullDescription: `\ diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 48ca7e7e3..b17bfef8a 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -1335,6 +1335,7 @@ export async function fetchMultipleEvents( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of one or more events", fullDescription: diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 90b2521c5..696e38391 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -677,6 +677,7 @@ function buildResultHint(rowCount: number, nav: string): string | undefined { // --------------------------------------------------------------------------- export const exploreCommand = buildListCommand("explore", { + exposure: "exposed", docs: { brief: topLevelBrief("explore"), fullDescription: diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 6f634a9b0..400c30275 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -132,6 +132,7 @@ function jsonTransformFeedbackList( } export const listCommand = buildListCommand("feedback", { + exposure: "exposed", docs: { brief: "List and search User Feedback", fullDescription: diff --git a/packages/cli/src/commands/feedback/resolve.ts b/packages/cli/src/commands/feedback/resolve.ts index c163d728d..8979faeb3 100644 --- a/packages/cli/src/commands/feedback/resolve.ts +++ b/packages/cli/src/commands/feedback/resolve.ts @@ -12,6 +12,7 @@ import { updateFeedbackStatus } from "./utils.js"; import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const resolveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Mark User Feedback as resolved", fullDescription: diff --git a/packages/cli/src/commands/feedback/spam.ts b/packages/cli/src/commands/feedback/spam.ts index 061fd861f..3cce700c6 100644 --- a/packages/cli/src/commands/feedback/spam.ts +++ b/packages/cli/src/commands/feedback/spam.ts @@ -12,6 +12,7 @@ import { updateFeedbackStatus } from "./utils.js"; import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const spamCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Mark User Feedback as spam", fullDescription: diff --git a/packages/cli/src/commands/feedback/unresolve.ts b/packages/cli/src/commands/feedback/unresolve.ts index 8e0666234..34e67c68b 100644 --- a/packages/cli/src/commands/feedback/unresolve.ts +++ b/packages/cli/src/commands/feedback/unresolve.ts @@ -12,6 +12,7 @@ import { updateFeedbackStatus } from "./utils.js"; import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const unresolveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Return User Feedback to the inbox", fullDescription: diff --git a/packages/cli/src/commands/feedback/view.ts b/packages/cli/src/commands/feedback/view.ts index dd3fca134..ec1eccb7e 100644 --- a/packages/cli/src/commands/feedback/view.ts +++ b/packages/cli/src/commands/feedback/view.ts @@ -94,6 +94,7 @@ function jsonTransformFeedbackView( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View a User Feedback item", fullDescription: diff --git a/packages/cli/src/commands/games/leaderboard.ts b/packages/cli/src/commands/games/leaderboard.ts index 8f820948b..ab101a0c5 100644 --- a/packages/cli/src/commands/games/leaderboard.ts +++ b/packages/cli/src/commands/games/leaderboard.ts @@ -35,6 +35,7 @@ function formatLeaderboardHuman(data: LeaderboardData): string { } export const leaderboardCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Show the top Snake scores from the last 30 days", fullDescription: diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts index 2946c0caa..d19925d5f 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -11,6 +11,7 @@ import { ValidationError } from "../../lib/errors.js"; import { isPlainOutput } from "../../lib/formatters/plain-detect.js"; export const snakeCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Play Snake in your terminal", fullDescription: diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 21ce6c8e3..79126ae1d 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -23,6 +23,7 @@ import { import { topLevelBrief } from "../top-level-briefs.js"; export const helpCommand = buildCommand({ + exposure: "exposed", auth: false, docs: { brief: topLevelBrief("help"), diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 0514f8e51..21268e243 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -111,6 +111,7 @@ function formatInfo(data: InfoStatus): string { } export const infoCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: topLevelBrief("info"), fullDescription: diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index bd70634d3..fe9e32661 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -39,6 +39,8 @@ function formatGuide(data: InitOutput): string { export const initCommand = buildCommand({ auth: false, + // Prints a static guide: no requests, no local files + exposure: "exposed", skipRcUrlCheck: true, docs: { brief: topLevelBrief("init"), diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index 8c0b598c9..beee065a1 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -150,6 +150,7 @@ function enrichDeleteError(error: unknown, scopeMissing: boolean): unknown { } export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Remove an installed integration", customUsage: [USAGE], diff --git a/packages/cli/src/commands/issue/archive.ts b/packages/cli/src/commands/issue/archive.ts index f6cabefc1..e874b7058 100644 --- a/packages/cli/src/commands/issue/archive.ts +++ b/packages/cli/src/commands/issue/archive.ts @@ -325,6 +325,7 @@ function specToApiOptions(spec: UntilSpec): { } export const archiveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Archive (ignore) an issue", fullDescription: diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index 09abcba89..511c7f3e0 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -68,6 +68,7 @@ function prevPageHint( // --------------------------------------------------------------------------- export const eventsCommand = buildListCommand("issue", { + exposure: "exposed", docs: { brief: "List events for a specific issue", fullDescription: diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index aaaf8392c..aeff54021 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -80,6 +80,7 @@ async function analyzeIssue( } export const explainCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Analyze one or more issues using Seer AI", fullDescription: diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 1f46bed53..6ef17aff2 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -23,6 +23,7 @@ type LinkFlags = { }; export const linkCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Link an existing external issue", fullDescription: diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 2a01252e7..f4498653d 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -1638,6 +1638,7 @@ const issueListOutput = { // --------------------------------------------------------------------------- export const listCommand = buildListCommand("issue", { + exposure: "exposed", docs: { brief: "List issues in a project", fullDescription: diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 358dcb624..586b1ffed 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -258,6 +258,7 @@ async function orderForMerge( } export const mergeCommand = buildCommand({ + exposure: "exposed", destructive: true, docs: { brief: "Merge 2+ issues into a single canonical group", diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 204b16f3b..c655fc31c 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -204,6 +204,7 @@ async function confirmRootCauseAndContinue( } export const planCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Generate a solution plan using Seer AI", fullDescription: diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index a5cebcb47..d3af171d5 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -97,6 +97,7 @@ function jsonTransform(result: ResolveResult, fields?: string[]): unknown { } export const resolveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Mark an issue as resolved", fullDescription: diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index 8554181c7..2d4a2e961 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -36,6 +36,7 @@ type TagsFlags = { }; export const tagsCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Show the top values of an issue tag", fullDescription: diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index 944568157..133932fcc 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -25,6 +25,7 @@ type UnlinkFlags = { }; export const unlinkCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Unlink an external issue", fullDescription: diff --git a/packages/cli/src/commands/issue/unresolve.ts b/packages/cli/src/commands/issue/unresolve.ts index 7aad926e2..8e8316877 100644 --- a/packages/cli/src/commands/issue/unresolve.ts +++ b/packages/cli/src/commands/issue/unresolve.ts @@ -27,6 +27,7 @@ function formatUnresolved(issue: SentryIssue): string { } export const unresolveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Reopen a resolved issue", fullDescription: diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index 9f655c3dd..425a42e42 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -314,6 +314,7 @@ async function openIssuesInBrowser( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of one or more issues", fullDescription: diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index 52b8d6c0a..f55daa515 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -147,6 +147,7 @@ async function stopLocalDaemon(state: LocalDaemonState): Promise { } const daemonStartCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Start the Local control-plane daemon" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { @@ -169,6 +170,7 @@ const daemonStartCommand = buildCommand({ }); const daemonStatusCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Show Local control-plane status" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, @@ -181,6 +183,7 @@ const daemonStatusCommand = buildCommand({ }); const daemonStopCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Stop the Local control-plane daemon" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, @@ -196,6 +199,7 @@ const daemonStopCommand = buildCommand({ }); const daemonRestartCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Restart the Local control-plane daemon" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, @@ -215,6 +219,7 @@ const daemonRestartCommand = buildCommand({ }); const sessionCreateCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Create a Local telemetry session" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { @@ -259,6 +264,7 @@ const sessionCreateCommand = buildCommand({ }); const sessionListCommand = buildCommand({ + exposure: "hidden", docs: { brief: "List Local telemetry sessions" }, ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, @@ -274,6 +280,7 @@ function buildSessionCommand( method: "GET" | "POST" = "GET", ): ReturnType { return buildCommand({ + exposure: "hidden", ...CONTROL_COMMAND_DEFAULTS, docs: { brief }, parameters: { flags: {}, positional: SESSION_REFERENCE_POSITIONAL }, @@ -294,6 +301,7 @@ function buildTelemetryViewCommand( brief: string, ): ReturnType { return buildCommand({ + exposure: "hidden", ...CONTROL_COMMAND_DEFAULTS, docs: { brief }, parameters: { @@ -323,6 +331,7 @@ function buildTelemetryViewCommand( } const sessionRetentionCommand = buildCommand({ + exposure: "hidden", ...CONTROL_COMMAND_DEFAULTS, docs: { brief: "View or change session envelope retention" }, parameters: { diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index ad7654034..bf3983da0 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -386,6 +386,7 @@ async function resolveArgs( } export const runCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Run a command with the local dev server enabled", fullDescription: diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index a4a12c01e..263436b58 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1012,6 +1012,7 @@ function printLocalEventLine(line: string, useJson: boolean): void { } export const serverCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Start the local dev server and tail events", fullDescription: diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 94328a474..d3c734498 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -767,6 +767,7 @@ function validateFollowFlags(flags: ListFlags): void { export const listCommand = buildListCommand( "log", { + exposure: "exposed", docs: { brief: "List logs from a project", fullDescription: diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index f254b22b4..f14047c60 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -450,6 +450,7 @@ function formatLogViewHuman(data: LogViewData): string { } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of one or more log entries", fullDescription: diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index e82078d02..4785819ab 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -63,6 +63,7 @@ function deprecatedBooleanFlag(replacement: string) { } export const mcpCommand = buildCommand({ + exposure: "hidden", // The server resolves its own credential (the CLI session or env token), // with its own `.sentryclirc` URL check. auth: false, diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index f48a85d1a..4250227fe 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -143,6 +143,7 @@ async function sendCheckInSafely( } export const runCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Wrap a command with cron monitor check-ins", fullDescription: `\ diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index f50c03cc8..8036476e4 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -381,6 +381,7 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { } export const listCommand = buildCommand({ + exposure: "exposed", docs: { brief: "List organizations", fullDescription: diff --git a/packages/cli/src/commands/org/view.ts b/packages/cli/src/commands/org/view.ts index 7a422983c..d8f8f6581 100644 --- a/packages/cli/src/commands/org/view.ts +++ b/packages/cli/src/commands/org/view.ts @@ -27,6 +27,7 @@ type ViewFlags = { }; export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of an organization", fullDescription: diff --git a/packages/cli/src/commands/platform/list.ts b/packages/cli/src/commands/platform/list.ts index 435253cbe..e84035d7e 100644 --- a/packages/cli/src/commands/platform/list.ts +++ b/packages/cli/src/commands/platform/list.ts @@ -34,6 +34,7 @@ function formatPlatformsHuman(list: readonly string[]): string { } export const listCommand = buildCommand({ + exposure: "exposed", auth: false, docs: { brief: "List all valid Sentry platform identifiers", diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index cdf546248..d255448f4 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -148,6 +148,7 @@ function deduplicateMappings(mappings: ProguardMapping[]): ProguardMapping[] { // ── Command ───────────────────────────────────────────────────────── export const uploadCommand = buildCommand({ + exposure: "stdio-only", // Auth is not required for --no-upload (dry-run mode). // The upload path calls resolveOrgAndProject which triggers auth. auth: false, diff --git a/packages/cli/src/commands/proguard/uuid.ts b/packages/cli/src/commands/proguard/uuid.ts index c79f9e55b..64f159464 100644 --- a/packages/cli/src/commands/proguard/uuid.ts +++ b/packages/cli/src/commands/proguard/uuid.ts @@ -23,6 +23,7 @@ type ProguardUuidResult = { }; export const uuidCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Compute the UUID for a ProGuard mapping file", fullDescription: diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index a86a3e656..256ce7e47 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -670,6 +670,7 @@ async function createOneProject(opts: { } export const createCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create one or more projects", customUsage: ["[/]:..."], diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index bf1d8c7c3..d0fb0df5d 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -130,6 +130,7 @@ type DeleteFlags = { }; export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Delete a project", fullDescription: diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 30d76adf5..fcccd5993 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -849,6 +849,7 @@ const projectListMeta: ListCommandMeta = { }; export const listCommand = buildListCommand("project", { + exposure: "exposed", docs: { brief: "List projects", fullDescription: diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index 66f8ae85f..47875122f 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -332,6 +332,7 @@ async function resolveViewTargets( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of a project", fullDescription: diff --git a/packages/cli/src/commands/react-native/gradle.ts b/packages/cli/src/commands/react-native/gradle.ts index 51d09e86e..2207422ef 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -123,6 +123,7 @@ function buildArtifacts( } export const gradleCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Upload a React Native bundle + sourcemap (Gradle build step)", fullDescription: diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index fc980215a..e280edaee 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -425,6 +425,7 @@ async function preparePair( } export const xcodeCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Upload React Native sourcemaps (Xcode build step)", fullDescription: diff --git a/packages/cli/src/commands/release/archive.ts b/packages/cli/src/commands/release/archive.ts index d9ac6da89..38ba67980 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -45,6 +45,7 @@ function formatReleaseArchived(release: ReleaseUpdateOutput): string { } export const archiveCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Archive a release", fullDescription: diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 7e0ff224b..f8cd1c744 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -53,6 +53,7 @@ function formatReleaseCreated(release: SentryRelease): string { } export const createCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create a release", fullDescription: diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index d318769a1..126056cdf 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -77,6 +77,7 @@ type DeleteFlags = { }; export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Delete a release", fullDescription: "Permanently delete a Sentry release.", diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index e1d509594..4f51bfba7 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -81,6 +81,7 @@ function parseDeployArgs( } export const deployCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Create a deploy for a release", fullDescription: diff --git a/packages/cli/src/commands/release/deploys.ts b/packages/cli/src/commands/release/deploys.ts index 35827b5ab..85627a4bc 100644 --- a/packages/cli/src/commands/release/deploys.ts +++ b/packages/cli/src/commands/release/deploys.ts @@ -33,6 +33,7 @@ function formatDeployList(deploys: SentryDeploy[]): string { } export const deploysCommand = buildCommand({ + exposure: "exposed", docs: { brief: "List deploys for a release", fullDescription: "List all deploys recorded for a specific release.", diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index ca01ac49e..d2e6d12fc 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -47,6 +47,7 @@ function formatReleaseFinalized(release: FinalizeOutput): string { } export const finalizeCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Finalize a release", fullDescription: "Mark a release as finalized by setting its release date.", diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index fdcf83040..112742960 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -633,6 +633,7 @@ type ListFlags = { // --------------------------------------------------------------------------- export const listCommand = buildListCommand("release", { + exposure: "exposed", docs: { brief: "List releases with adoption and health metrics", fullDescription: diff --git a/packages/cli/src/commands/release/propose-version.ts b/packages/cli/src/commands/release/propose-version.ts index 01ac8abc3..cd50bb113 100644 --- a/packages/cli/src/commands/release/propose-version.ts +++ b/packages/cli/src/commands/release/propose-version.ts @@ -62,6 +62,7 @@ const CI_ENV_VARS: ReadonlyArray = [ ]; export const proposeVersionCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Propose a release version", fullDescription: diff --git a/packages/cli/src/commands/release/restore.ts b/packages/cli/src/commands/release/restore.ts index 96ff504c5..8699535eb 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -44,6 +44,7 @@ function formatReleaseRestored(release: ReleaseUpdateOutput): string { } export const restoreCommand = buildCommand({ + exposure: "exposed", docs: { brief: "Restore an archived release", fullDescription: diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index 95e5c278f..5ea1393aa 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -364,6 +364,7 @@ function formatCommitsSet(release: SentryRelease): string { } export const setCommitsCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Set commits for a release", fullDescription: diff --git a/packages/cli/src/commands/release/view.ts b/packages/cli/src/commands/release/view.ts index aa2b15acf..870f0d88b 100644 --- a/packages/cli/src/commands/release/view.ts +++ b/packages/cli/src/commands/release/view.ts @@ -154,6 +154,7 @@ function formatReleaseDetails(release: SentryRelease): string { } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View release details with health metrics", fullDescription: diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index a6228fb80..e65c12ac8 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -49,6 +49,7 @@ const USAGE_HINT = const log = logger.withTag("replay.download"); export const downloadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Download a Session Replay as rrweb JSON", fullDescription: diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 5a5d0e079..bce3eb065 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -251,6 +251,7 @@ function jsonTransformReplayList( } export const listCommand = buildListCommand("replay", { + exposure: "exposed", docs: { brief: "List recent Session Replays", fullDescription: diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index e8399d039..c9025e526 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -309,6 +309,7 @@ async function enrichReplayView( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View a Session Replay", fullDescription: diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 68451c102..00f0aa372 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -440,6 +440,7 @@ type SchemaFlags = { }; export const schemaCommand = buildCommand({ + exposure: "exposed", auth: false, docs: { brief: topLevelBrief("schema"), diff --git a/packages/cli/src/commands/send-envelope.ts b/packages/cli/src/commands/send-envelope.ts index 97e09b29f..912efaf72 100644 --- a/packages/cli/src/commands/send-envelope.ts +++ b/packages/cli/src/commands/send-envelope.ts @@ -11,6 +11,7 @@ import { parseFilePath, parseText } from "../lib/completion-target.js"; import { CliError, EXIT } from "../lib/errors.js"; export const sendEnvelopeCommand = buildCommand({ + exposure: "hidden", docs: { brief: "Send a Sentry envelope file (deprecated)", fullDescription: diff --git a/packages/cli/src/commands/snapshots/diff.ts b/packages/cli/src/commands/snapshots/diff.ts index a35b9a2a7..ee5f4769f 100644 --- a/packages/cli/src/commands/snapshots/diff.ts +++ b/packages/cli/src/commands/snapshots/diff.ts @@ -224,6 +224,7 @@ function formatDiffReport(data: DiffReport): string { } export const diffCommand = buildCommand({ + exposure: "stdio-only", // Local-only: reads two image directories, no network access. auth: false, docs: { diff --git a/packages/cli/src/commands/snapshots/download.ts b/packages/cli/src/commands/snapshots/download.ts index 88dd04191..65d4a2cee 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -151,6 +151,7 @@ async function resolveSnapshotId( } export const downloadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Download baseline snapshot images", fullDescription: diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index f57fcfc89..c0f549d13 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -344,6 +344,7 @@ function formatUploadResult(data: SnapshotUploadResult): string { } export const uploadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Upload snapshots to a project", fullDescription: diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index 9705e2492..eeab4ebef 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -61,6 +61,7 @@ function formatInjectResult(data: InjectCommandResult): string { } export const injectCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Inject debug IDs into JavaScript files and sourcemaps", fullDescription: diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index 4f1599713..acbc6869a 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -109,6 +109,7 @@ function formatResolveResult(data: ResolveCommandResult): string { } export const resolveCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Resolve and report sourcemap linkage for JavaScript files", fullDescription: diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 9a8849a61..e270cb73a 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -212,6 +212,7 @@ function buildArtifactPair( } export const uploadCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Upload sourcemaps to Sentry", fullDescription: diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index c9c5aed5a..346824b78 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -602,6 +602,7 @@ async function handleProjectMode( // --------------------------------------------------------------------------- export const listCommand = buildListCommand("span", { + exposure: "exposed", docs: { brief: "List spans in a project or trace", fullDescription: diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index 4b2f29c84..eadffadb5 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -347,6 +347,7 @@ function jsonTransformSpanView(data: SpanViewData, fields?: string[]): unknown { } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of specific spans", fullDescription: diff --git a/packages/cli/src/commands/status/show.ts b/packages/cli/src/commands/status/show.ts index de5adbf5c..c0c30028c 100644 --- a/packages/cli/src/commands/status/show.ts +++ b/packages/cli/src/commands/status/show.ts @@ -23,6 +23,7 @@ type ShowFlags = { }; export const showCommand = buildCommand({ + exposure: "exposed", // Checking the public status page requires no Sentry credentials. auth: false, docs: { diff --git a/packages/cli/src/commands/token/create.ts b/packages/cli/src/commands/token/create.ts index f7b69b305..bcfc25a05 100644 --- a/packages/cli/src/commands/token/create.ts +++ b/packages/cli/src/commands/token/create.ts @@ -66,6 +66,9 @@ function formatTokenCreated(result: CreateResult): string { } export const createCommand = buildCommand({ + // Prints a new credential, meant for local CI setup; hosted MCP must not + // mint tokens under the user's grant + exposure: "stdio-only", docs: { brief: "Create an org auth token", fullDescription: diff --git a/packages/cli/src/commands/token/delete.ts b/packages/cli/src/commands/token/delete.ts index 6760df266..73f86486a 100644 --- a/packages/cli/src/commands/token/delete.ts +++ b/packages/cli/src/commands/token/delete.ts @@ -184,6 +184,7 @@ async function findToken(org: string, ref: TokenRef): Promise { } export const deleteCommand = buildDeleteCommand({ + exposure: "exposed", docs: { brief: "Revoke an org auth token", customUsage: [USAGE], diff --git a/packages/cli/src/commands/token/list.ts b/packages/cli/src/commands/token/list.ts index 8ccfaf5b6..8ddc76f59 100644 --- a/packages/cli/src/commands/token/list.ts +++ b/packages/cli/src/commands/token/list.ts @@ -53,6 +53,10 @@ function formatTokenList(tokens: OrgAuthToken[]): string { } export const listCommand = buildCommand({ + exposure: "exposed", + // The listing is a GET; the POST the analysis finds is the OAuth refresh + // that `selectAccountForOrg` can reach + permission: "read", docs: { brief: "List org auth tokens", fullDescription: diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 38b58a82c..6b838c983 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -194,6 +194,7 @@ function jsonTransformTraceList( } export const listCommand = buildListCommand("trace", { + exposure: "exposed", docs: { brief: "List recent traces in a project", fullDescription: diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index 8e55e2385..9cee97f44 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -141,6 +141,7 @@ async function resolveTraceLogsTarget( } export const logsCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View logs associated with a trace", fullDescription: diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index c0cb654e8..cbf5853d6 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -465,6 +465,7 @@ function fetchTraceSpanDetails( } export const viewCommand = buildCommand({ + exposure: "exposed", docs: { brief: "View details of a specific trace", fullDescription: diff --git a/packages/cli/src/commands/trial/list.ts b/packages/cli/src/commands/trial/list.ts index dcc985903..75a831c2f 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -175,6 +175,7 @@ function formatTrialListHuman(entries: TrialListEntry[]): string { } export const listCommand = buildCommand({ + exposure: "exposed", docs: { brief: "List product trials", fullDescription: diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 951032158..8ec476e34 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -152,6 +152,7 @@ export function parseTrialStartArguments( } export const startCommand = buildCommand({ + exposure: "exposed", destructive: true, docs: { brief: "Start a product trial", diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index a4673b003..05519e29d 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -55,6 +55,7 @@ type WasmSplitResult = { }; export const wasmSplitCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: topLevelBrief("wasm-split"), fullDescription: diff --git a/packages/cli/src/lib/command-access.ts b/packages/cli/src/lib/command-access.ts new file mode 100644 index 000000000..edbd54213 --- /dev/null +++ b/packages/cli/src/lib/command-access.ts @@ -0,0 +1,89 @@ +/** + * What a command may do (permission class) and where agents may run it + * (exposure). `sentry mcp` lists and runs commands by these fields: + * `execute_sentry_read` runs only read commands, and hosts allowlist by + * permission class (#1254). `check:command-access` keeps them honest. + */ + +/** + * Where agents may run a command: + * - `exposed`: on stdio and hosted MCP. + * - `stdio-only`: needs the user's machine (local files, the `cwd`, git, + * the local credential store, or a terminal). + * - `hidden`: never offered to agents (session management, CLI + * maintenance, `local`, `monitor run`). + */ +export type CommandExposure = "exposed" | "stdio-only" | "hidden"; + +/** Accepted {@link CommandExposure} values. */ +export const COMMAND_EXPOSURES: readonly CommandExposure[] = [ + "exposed", + "stdio-only", + "hidden", +]; + +/** + * What a command may do to Sentry: `read` sends only GET, HEAD, or OPTIONS; + * `write` sends anything else; `destructive` is any command built with + * `destructive: true` or `buildDeleteCommand`. + */ +type PermissionClass = "read" | "write" | "destructive"; + +/** + * A command's {@link PermissionClass}, or `method` when each call takes the + * class of the HTTP method it sends (`sentry api --method`). + */ +export type CommandPermission = PermissionClass | "method"; + +/** + * A permission a command declares itself. Most commands declare none: their + * class comes from the Sentry API requests they send. `check:command-access` + * asks for a declaration only where those requests can't settle it. + */ +export type DeclaredPermission = Exclude; + +/** + * Class of the Sentry API requests a command sends, generated by + * `generate:command-api`. `unknown`: a request whose method the source + * analysis can't read. Commands that send no API request have none. + */ +export type ApiAccess = "read" | "write" | "unknown"; + +const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** Whether `method` is an HTTP method that changes state. */ +export function isWriteMethod(method: string): boolean { + return WRITE_METHODS.has(method); +} + +/** + * Class of a command's Sentry API requests, given their methods + * (`undefined` for a request whose method is unknown). + */ +export function apiAccess( + methods: readonly (string | undefined)[], +): ApiAccess | undefined { + if (methods.some((method) => method !== undefined && isWriteMethod(method))) + return "write"; + if ( + methods.some((method) => method === undefined || !READ_METHODS.has(method)) + ) + return "unknown"; + return methods.length > 0 ? "read" : undefined; +} + +/** + * A command's permission: `destructive` when flagged, else its declaration, + * else the class of its API requests. A command without API requests reads; + * an unknown request counts as a write until the command declares a class. + */ +export function commandPermission(command: { + destructive?: boolean; + declared?: DeclaredPermission; + api?: ApiAccess; +}): CommandPermission { + if (command.destructive) return "destructive"; + if (command.declared) return command.declared; + return command.api === undefined || command.api === "read" ? "read" : "write"; +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 1edb64535..0099a1fd3 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -41,6 +41,7 @@ import type { GenericSchema } from "valibot"; import type { Writer } from "../types/index.js"; import { asRunningAppCommand } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; +import type { CommandExposure, DeclaredPermission } from "./command-access.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { choiceListParser, @@ -226,6 +227,19 @@ type LocalCommandBuilderArguments< * `issue merge`, `trial start`). */ readonly destructive?: boolean; + /** + * Where agents may run the command: `exposed`, `stdio-only` (needs local + * files, the `cwd`, or a terminal), or `hidden`. Every command declares one. + */ + readonly exposure: CommandExposure; + /** + * Permission class, for commands whose Sentry API requests can't settle + * it: requests outside the Sentry API, requests the source analysis can't + * follow, or `method` for `sentry api`. Other commands get their class + * from their API requests, and `check:command-access` rejects a + * declaration there. Destructive commands never declare one. + */ + readonly permission?: DeclaredPermission; }; // --------------------------------------------------------------------------- @@ -975,6 +989,11 @@ export function buildCommand< if (builderArgs.destructive) { (cmd as unknown as Record).__destructive = true; } + (cmd as unknown as Record).__exposure = builderArgs.exposure; + if (builderArgs.permission) { + (cmd as unknown as Record).__permission = + builderArgs.permission; + } // Attach the JSON fields to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 26711cf11..484b85590 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -11,11 +11,21 @@ */ import generatedCommandApi from "../generated/command-api.json"; +import generatedCommandApiAccess from "../generated/command-api-access.json"; +import { + type ApiAccess, + type CommandExposure, + type CommandPermission, + commandPermission, + type DeclaredPermission, +} from "./command-access.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { CHOICE_TARGET, type CompletionTarget, + DIRECTORY_TARGET, type DatasetCompletion, + FILE_TARGET, getCompletionChoices, getCompletionDatasets, getCompletionTarget, @@ -71,6 +81,10 @@ export type Command = { __examples?: readonly CommandExample[]; /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ __destructive?: boolean; + /** Set by `buildCommand({ exposure })`; every command declares one. */ + __exposure?: CommandExposure; + /** Set by `buildCommand({ permission })` where API requests can't settle it. */ + __permission?: DeclaredPermission; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -133,6 +147,10 @@ export type CommandInfo = { jsonFields?: SchemaFieldInfo[]; /** Present (and `true`) when the command is destructive or irreversible */ destructive?: true; + /** What the command may do to Sentry (`lib/command-access.ts`) */ + permission: CommandPermission; + /** Where agents may run the command; `hidden` when it declares none */ + exposure: CommandExposure; }; /** Extracted metadata for a single flag */ @@ -144,6 +162,11 @@ export type FlagInfo = { optional: boolean; variadic: boolean; hidden: boolean; + /** + * Present (and `true`) when the flag takes a local file or directory path + * (`parseFilePath`, `parseDirectoryPath`), which only stdio can serve. + */ + localPath?: true; }; /** Extracted metadata for a route group or standalone command */ @@ -276,6 +299,12 @@ export function extractPositionals( return []; } +/** Completion kinds of flags that take a path on the local machine. */ +const LOCAL_PATH_TARGETS: ReadonlySet = new Set([ + FILE_TARGET, + DIRECTORY_TARGET, +]); + /** * Extract flag metadata from a command's flag definitions. * @@ -297,6 +326,9 @@ export function extractFlags( optional: def.optional ?? def.kind === "boolean", variadic: def.variadic ?? false, hidden: def.hidden ?? false, + ...(LOCAL_PATH_TARGETS.has(getCompletionTarget(def.parse)) + ? { localPath: true as const } + : {}), })); } @@ -306,7 +338,13 @@ export function extractFlags( * @param cmd - The Stricli command to introspect * @param path - Full command path (e.g. "sentry issue list") */ -export function buildCommandInfo(cmd: Command, path: string): CommandInfo { +export function buildCommandInfo( + cmd: Command, + path: string, + apiAccessByCommand: Readonly< + Record + > = generatedCommandApiAccess as Record, +): CommandInfo { const positionals = extractPositionals(cmd.parameters.positional); const [positional] = positionals; if (cmd.__primaryUsage && positional && positionals.length === 1) { @@ -328,6 +366,12 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ), jsonFields: cmd.__jsonFields?.length ? [...cmd.__jsonFields] : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), + permission: commandPermission({ + destructive: cmd.__destructive, + declared: cmd.__permission, + api: apiAccessByCommand[path], + }), + exposure: cmd.__exposure ?? "hidden", }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 758c4a75f..51c12a718 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -17,6 +17,7 @@ import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { GenericSchema } from "valibot"; import type { SentryContext } from "../context.js"; import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; +import type { CommandExposure } from "./command-access.js"; import { buildCommand, type CommandDocumentation, @@ -539,6 +540,7 @@ export function buildListCommand< readonly func: SentryCommandFunction>; readonly output?: OutputConfig; readonly auth?: boolean; + readonly exposure: CommandExposure; }, options?: ListCommandOptions, ): Command { @@ -693,6 +695,8 @@ export function buildOrgListCommand( schema: config.schema, }; return buildListCommand(routeName, { + // Org-scoped API listings: nothing here needs the user's machine. + exposure: "exposed", docs, output, parameters: { diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index cac7535ac..934cc849c 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -20,6 +20,7 @@ import { isatty } from "node:tty"; import type { Command, CommandContext } from "@stricli/core"; import type { GenericSchema } from "valibot"; import type { ParsedOrgProject } from "./arg-parsing.js"; +import type { CommandExposure } from "./command-access.js"; import { buildCommand, type CommandDocumentation, @@ -313,6 +314,7 @@ export function buildDeleteCommand< readonly func: SentryCommandFunction>; readonly output?: OutputConfig; readonly auth?: boolean; + readonly exposure: CommandExposure; }, options?: DeleteCommandOptions, ): Command { diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 7bc76e56d..e82dfb4d9 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -20,6 +20,7 @@ import { ISSUE_TARGET, ORG_PROJECT_TARGET, ORG_TARGET, + parseDirectoryPath, parseFilePath, TEXT_TARGET, textParser, @@ -216,6 +217,21 @@ describe("extractFlags", () => { ]); }); + test("marks flags that take a local path", () => { + const flags: Record = { + input: { kind: "parsed", parse: parseFilePath, optional: true }, + dir: { kind: "parsed", parse: parseDirectoryPath, optional: true }, + name: { kind: "parsed", parse: textParser(String), optional: true }, + }; + expect( + extractFlags(flags).map(({ name, localPath }) => [name, localPath]), + ).toEqual([ + ["input", true], + ["dir", true], + ["name", undefined], + ]); + }); + test("extracts parsed flag", () => { const flags: Record = { limit: { @@ -334,6 +350,43 @@ describe("buildCommandInfo", () => { syntax: ":...", }); }); + + test("reports exposure and the permission class from API requests, declaration, and destructive flag", () => { + const access = { + "sentry thing list": "read", + "sentry thing edit": "write", + "sentry thing sync": "unknown", + } as const; + const info = (cmd: Command, path: string) => + buildCommandInfo(cmd, path, access); + expect( + info(makeCommand({ __exposure: "exposed" }), "sentry thing list"), + ).toMatchObject({ permission: "read", exposure: "exposed" }); + expect( + info(makeCommand({ __exposure: "exposed" }), "sentry thing edit"), + ).toMatchObject({ permission: "write" }); + // A request the analysis can't classify counts as a write + expect(info(makeCommand(), "sentry thing sync")).toMatchObject({ + permission: "write", + }); + expect( + info( + makeCommand({ __exposure: "stdio-only", __permission: "write" }), + "sentry thing send", + ), + ).toMatchObject({ permission: "write", exposure: "stdio-only" }); + expect( + info(makeCommand({ __permission: "method" }), "sentry api"), + ).toMatchObject({ permission: "method" }); + expect( + info(makeCommand({ __destructive: true }), "sentry thing edit"), + ).toMatchObject({ permission: "destructive" }); + // A command without API requests reads; one without an exposure is never offered + expect(info(makeCommand(), "sentry thing help")).toMatchObject({ + permission: "read", + exposure: "hidden", + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/test/script/command-access.test.ts b/packages/cli/test/script/command-access.test.ts new file mode 100644 index 000000000..5427caa55 --- /dev/null +++ b/packages/cli/test/script/command-access.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + allCommands, + type CommandRequests, + commandAccessProblems, +} from "../../script/lib/command-access.js"; +import { + analyzeSourceApiUsage, + type SourceApiOperation, +} from "../../script/lib/source-api-usage.js"; +import type { DeclaredPermission } from "../../src/lib/command-access.js"; +import { buildCommand } from "../../src/lib/command.js"; +import type { Command, RouteMap } from "../../src/lib/introspect.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; + +const operations: SourceApiOperation[] = [ + { + functionName: "listOrganizationEvents", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + }, + { + functionName: "updateOrganizationIssue", + method: "PUT", + path: "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/", + }, +]; + +/** Requests the analysis finds for each export of a planted command file. */ +function plantedRequests(): Map { + const sources = new Map([ + [ + "src/commands/planted.ts", + ` + import { listOrganizationEvents, updateOrganizationIssue } from "@sentry/api"; + export const list = buildCommand({ func: () => listOrganizationEvents({}) }); + export const edit = buildCommand({ func: () => updateOrganizationIssue({}) }); + export const send = buildCommand({ + func: () => fetch("https://o1.ingest.sentry.io/api/1/envelope/", { method: "POST" }), + }); + export const local = buildCommand({ func: () => readConfig() }); + `, + ], + ]); + const usage = analyzeSourceApiUsage(sources, operations).exports.get( + "src/commands/planted.ts", + )!; + return new Map( + [...usage].map(([name, { methods, apiMethods }]) => [ + name, + { methods, apiMethods }, + ]), + ); +} + +function command( + access: { + exposure?: "exposed" | "stdio-only" | "hidden"; + permission?: DeclaredPermission; + destructive?: boolean; + } = {}, +): Command { + return buildCommand({ + exposure: "exposed", + ...access, + docs: { brief: "Planted command" }, + auth: false, + async *func() { + yield; + }, + } as Parameters[0]) as unknown as Command; +} + +describe("command access check", () => { + const requests = plantedRequests(); + const problems = ( + name: string, + access?: Parameters[0], + ): string[] => commandAccessProblems(command(access), requests.get(name)); + + it("fails a GET-only command marked write", () => { + expect(problems("list", { permission: "write" })).toEqual([ + 'declares `permission: "write"`, but its Sentry API requests are read-only', + ]); + }); + + it("fails a write command marked read", () => { + expect(problems("edit", { permission: "read" })).toEqual([ + 'declares `permission: "read"`, but it sends PUT Sentry API requests', + ]); + }); + + it("derives the class from API requests and rejects redundant declarations", () => { + expect(problems("list")).toEqual([]); + expect(problems("edit")).toEqual([]); + expect(problems("edit", { permission: "write" })).toEqual([ + 'declares `permission: "write"`, which its API requests already give; drop it', + ]); + expect(problems("local", { permission: "read" })).toEqual([ + 'declares `permission: "read"`, which its requests already give; drop it', + ]); + }); + + it("asks for a declaration where API requests can't settle the class", () => { + expect(problems("send")).toEqual([ + 'sends POST requests outside the Sentry API; declare `permission: "read"` or `"write"`', + ]); + expect(problems("send", { permission: "write" })).toEqual([]); + expect(problems("send", { permission: "method" })).toEqual([ + 'declares `permission: "method"`, but it sends POST requests outside the Sentry API; declare `permission: "read"` or `"write"`', + ]); + expect(problems("unanalyzed")).toEqual([ + "has no source export the API analysis can follow; declare `permission`", + ]); + expect(problems("unanalyzed", { exposure: "hidden" })).toEqual([]); + expect(problems("local", { permission: "method" })).toEqual([]); + expect(problems("list", { permission: "method" })).toEqual([ + 'declares `permission: "method"`, but its Sentry API requests are read-only', + ]); + }); + + it("fails a command without exposure and a destructive command that declares a class", () => { + expect( + commandAccessProblems( + { ...command(), __exposure: undefined }, + requests.get("list"), + ), + ).toEqual([ + "declares no exposure (exposed, stdio-only, hidden); pass `exposure` to its builder", + ]); + expect( + problems("edit", { destructive: true, permission: "write" }), + ).toEqual([ + 'is destructive, which sets its class; drop `permission: "write"`', + ]); + }); + + it("checks commands under hidden routes", () => { + const visible = command(); + const hidden = command(); + const tree = buildRouteMap({ + routes: { + group: buildRouteMap({ + routes: { visible }, + docs: { brief: "Group" }, + }), + legacy: hidden, + }, + docs: { brief: "CLI", hideRoute: { legacy: true } }, + }) as unknown as RouteMap; + expect([...allCommands(tree).values()]).toEqual([ + "sentry group visible", + "sentry legacy", + ]); + }); +}); diff --git a/packages/cli/test/script/source-api-usage.test.ts b/packages/cli/test/script/source-api-usage.test.ts index c601d4605..d98d88b52 100644 --- a/packages/cli/test/script/source-api-usage.test.ts +++ b/packages/cli/test/script/source-api-usage.test.ts @@ -59,6 +59,8 @@ describe("source API usage", () => { operations: [], unresolvedRequests: [], deletes: false, + methods: [], + apiMethods: [], }); expect(analysis.exports.get("src/routes.ts")?.get("routes")?.deletes).toBe( false, @@ -179,6 +181,7 @@ describe("source API usage", () => { unresolvedRequests: [ { method: "DELETE", reason: "Dynamic request identity" }, ], + apiMethods: ["DELETE"], }); expect(result.get("privateApi")).toMatchObject({ deletes: true, @@ -223,6 +226,12 @@ describe("source API usage", () => { ).toMatchObject({ operations: [operations[2]], deletes: true }); expect( analysis.exports.get("src/commands/project.ts")?.get("safe"), - ).toEqual({ operations: [], unresolvedRequests: [], deletes: false }); + ).toEqual({ + operations: [], + unresolvedRequests: [], + deletes: false, + methods: [], + apiMethods: [], + }); }); });