From e2d4fbaf7c9d06a8bfa4045ebcad5e855e36150f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:52:41 +0000 Subject: [PATCH 1/3] feat(cli): Add a permission class and an MCP exposure to every command Every command now declares where agents may run it (`exposure`: exposed, stdio-only, or hidden), and introspection reports its permission class (read, write, destructive, or `method` for `sentry api`). The class comes from the Sentry API requests that `generate:command-api` finds, so commands declare `permission` only where those requests can't settle it. `check:command-access` fails when a command has no exposure or a declaration contradicts its requests. Closes #1880 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 3 + packages/cli/DEVELOPMENT.md | 8 + packages/cli/package.json | 3 +- packages/cli/script/check-command-access.ts | 54 +++++++ .../cli/script/check-destructive-commands.ts | 26 +-- packages/cli/script/ensure-generated.mjs | 1 + packages/cli/script/generate-command-api.ts | 25 ++- packages/cli/script/lib/command-access.ts | 138 ++++++++++++++++ packages/cli/script/lib/source-api-usage.ts | 36 ++++- .../src/commands/agent-conversation/list.ts | 1 + .../src/commands/agent-conversation/view.ts | 1 + .../cli/src/commands/alert/issues/create.ts | 1 + .../cli/src/commands/alert/issues/delete.ts | 1 + .../cli/src/commands/alert/issues/edit.ts | 1 + .../cli/src/commands/alert/issues/list.ts | 1 + .../cli/src/commands/alert/issues/view.ts | 1 + .../cli/src/commands/alert/metrics/create.ts | 1 + .../cli/src/commands/alert/metrics/delete.ts | 1 + .../cli/src/commands/alert/metrics/edit.ts | 1 + .../cli/src/commands/alert/metrics/list.ts | 1 + .../cli/src/commands/alert/metrics/view.ts | 1 + packages/cli/src/commands/api.ts | 2 + packages/cli/src/commands/attribute/list.ts | 1 + packages/cli/src/commands/auth/default.ts | 1 + packages/cli/src/commands/auth/list.ts | 1 + packages/cli/src/commands/auth/login.ts | 1 + packages/cli/src/commands/auth/logout.ts | 1 + packages/cli/src/commands/auth/refresh.ts | 1 + packages/cli/src/commands/auth/status.ts | 1 + packages/cli/src/commands/auth/switch.ts | 1 + packages/cli/src/commands/auth/token.ts | 1 + packages/cli/src/commands/auth/whoami.ts | 1 + packages/cli/src/commands/bash-hook.ts | 1 + packages/cli/src/commands/build/download.ts | 1 + packages/cli/src/commands/build/upload.ts | 1 + packages/cli/src/commands/cli/completion.ts | 1 + packages/cli/src/commands/cli/defaults.ts | 1 + packages/cli/src/commands/cli/feedback.ts | 1 + packages/cli/src/commands/cli/fix.ts | 1 + packages/cli/src/commands/cli/import.ts | 1 + packages/cli/src/commands/cli/setup.ts | 1 + packages/cli/src/commands/cli/uninstall.ts | 1 + packages/cli/src/commands/cli/upgrade.ts | 1 + .../cli/src/commands/code-mappings/upload.ts | 1 + .../src/commands/dart-symbol-map/upload.ts | 1 + packages/cli/src/commands/dashboard/create.ts | 1 + packages/cli/src/commands/dashboard/list.ts | 1 + .../cli/src/commands/dashboard/restore.ts | 1 + .../cli/src/commands/dashboard/revisions.ts | 1 + packages/cli/src/commands/dashboard/view.ts | 1 + .../cli/src/commands/dashboard/widget/add.ts | 1 + .../src/commands/dashboard/widget/delete.ts | 1 + .../cli/src/commands/dashboard/widget/edit.ts | 1 + .../src/commands/debug-files/bundle-jvm.ts | 1 + .../commands/debug-files/bundle-sources.ts | 1 + .../cli/src/commands/debug-files/check.ts | 1 + packages/cli/src/commands/debug-files/find.ts | 1 + .../src/commands/debug-files/print-sources.ts | 1 + .../cli/src/commands/debug-files/upload.ts | 1 + packages/cli/src/commands/docs/list.ts | 2 + packages/cli/src/commands/docs/query.ts | 2 + packages/cli/src/commands/dsn/list.ts | 1 + packages/cli/src/commands/event/list.ts | 1 + packages/cli/src/commands/event/send.ts | 2 + packages/cli/src/commands/event/view.ts | 1 + packages/cli/src/commands/explore.ts | 1 + packages/cli/src/commands/feedback/list.ts | 1 + packages/cli/src/commands/feedback/resolve.ts | 1 + packages/cli/src/commands/feedback/spam.ts | 1 + .../cli/src/commands/feedback/unresolve.ts | 1 + packages/cli/src/commands/feedback/view.ts | 1 + packages/cli/src/commands/help.ts | 1 + packages/cli/src/commands/info.ts | 1 + packages/cli/src/commands/init.ts | 1 + .../cli/src/commands/integration/delete.ts | 1 + packages/cli/src/commands/issue/archive.ts | 1 + packages/cli/src/commands/issue/events.ts | 1 + packages/cli/src/commands/issue/explain.ts | 1 + packages/cli/src/commands/issue/link.ts | 1 + packages/cli/src/commands/issue/list.ts | 1 + packages/cli/src/commands/issue/merge.ts | 1 + packages/cli/src/commands/issue/plan.ts | 1 + packages/cli/src/commands/issue/resolve.ts | 1 + packages/cli/src/commands/issue/tags.ts | 1 + packages/cli/src/commands/issue/unlink.ts | 1 + packages/cli/src/commands/issue/unresolve.ts | 1 + packages/cli/src/commands/issue/view.ts | 1 + packages/cli/src/commands/local/management.ts | 9 ++ packages/cli/src/commands/local/run.ts | 1 + packages/cli/src/commands/local/server.ts | 1 + packages/cli/src/commands/log/list.ts | 1 + packages/cli/src/commands/log/view.ts | 1 + packages/cli/src/commands/mcp.ts | 1 + packages/cli/src/commands/monitor/run.ts | 1 + packages/cli/src/commands/org/list.ts | 1 + packages/cli/src/commands/org/view.ts | 1 + packages/cli/src/commands/platform/list.ts | 1 + packages/cli/src/commands/proguard/upload.ts | 1 + packages/cli/src/commands/proguard/uuid.ts | 1 + packages/cli/src/commands/project/create.ts | 1 + packages/cli/src/commands/project/delete.ts | 1 + packages/cli/src/commands/project/list.ts | 1 + packages/cli/src/commands/project/view.ts | 1 + .../cli/src/commands/react-native/gradle.ts | 1 + .../cli/src/commands/react-native/xcode.ts | 1 + packages/cli/src/commands/release/archive.ts | 1 + packages/cli/src/commands/release/create.ts | 1 + packages/cli/src/commands/release/delete.ts | 1 + packages/cli/src/commands/release/deploy.ts | 1 + packages/cli/src/commands/release/deploys.ts | 1 + packages/cli/src/commands/release/finalize.ts | 1 + packages/cli/src/commands/release/list.ts | 1 + .../src/commands/release/propose-version.ts | 1 + packages/cli/src/commands/release/restore.ts | 1 + .../cli/src/commands/release/set-commits.ts | 1 + packages/cli/src/commands/release/view.ts | 1 + packages/cli/src/commands/replay/download.ts | 1 + packages/cli/src/commands/replay/list.ts | 1 + packages/cli/src/commands/replay/view.ts | 1 + packages/cli/src/commands/schema.ts | 1 + packages/cli/src/commands/send-envelope.ts | 1 + packages/cli/src/commands/snapshots/diff.ts | 1 + .../cli/src/commands/snapshots/download.ts | 1 + packages/cli/src/commands/snapshots/upload.ts | 1 + packages/cli/src/commands/sourcemap/inject.ts | 1 + .../cli/src/commands/sourcemap/resolve.ts | 1 + packages/cli/src/commands/sourcemap/upload.ts | 1 + packages/cli/src/commands/span/list.ts | 1 + packages/cli/src/commands/span/view.ts | 1 + packages/cli/src/commands/status/show.ts | 1 + packages/cli/src/commands/trace/list.ts | 1 + packages/cli/src/commands/trace/logs.ts | 1 + packages/cli/src/commands/trace/view.ts | 1 + packages/cli/src/commands/trial/list.ts | 1 + packages/cli/src/commands/trial/start.ts | 1 + packages/cli/src/commands/wasm-split.ts | 1 + packages/cli/src/lib/command-access.ts | 89 ++++++++++ packages/cli/src/lib/command.ts | 19 +++ packages/cli/src/lib/introspect.ts | 46 +++++- packages/cli/src/lib/list-command.ts | 4 + packages/cli/src/lib/mutate-command.ts | 2 + packages/cli/test/lib/introspect.test.ts | 53 ++++++ .../cli/test/script/command-access.test.ts | 153 ++++++++++++++++++ .../cli/test/script/source-api-usage.test.ts | 11 +- 144 files changed, 781 insertions(+), 29 deletions(-) create mode 100644 packages/cli/script/check-command-access.ts create mode 100644 packages/cli/script/lib/command-access.ts create mode 100644 packages/cli/src/lib/command-access.ts create mode 100644 packages/cli/test/script/command-access.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index a440e1b17..20f20a6fa 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -178,6 +178,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", @@ -206,6 +207,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 a8f1131ef..4597e7b08 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -120,6 +120,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 3330edc10..410a1e8c0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -94,7 +94,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 794aa8c2e..d0d666228 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -34,6 +34,7 @@ const GRAMMAR_OUTPUTS = [ /** Produced by `generate:docs`; imported at runtime by `src/`. */ const DOCS_OUTPUTS = [ "src/generated/command-api.json", + "src/generated/command-api-access.json", "src/generated/skill-content.ts", "src/generated/completion-targets.json", "src/generated/route-loaders.ts", diff --git a/packages/cli/script/generate-command-api.ts b/packages/cli/script/generate-command-api.ts index 8ae5497ae..0fb27e150 100644 --- a/packages/cli/script/generate-command-api.ts +++ b/packages/cli/script/generate-command-api.ts @@ -1,21 +1,28 @@ #!/usr/bin/env tsx -/** 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 { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { glob } from "tinyglobby"; import type { ApiEndpoint, ApiReference } from "../src/lib/api-schema.js"; +import { type ApiAccess, apiAccess } from "../src/lib/command-access.js"; import type { RouteMap, RouteTarget } from "../src/lib/introspect.js"; import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; const OUTPUT_PATH = "src/generated/command-api.json"; +const ACCESS_OUTPUT_PATH = "src/generated/command-api-access.json"; // The route tree imports these generated files. Bootstrap before importing it; // generate:skill replaces its stub later in the same generate:docs pipeline. await mkdir("src/generated", { recursive: true }); for (const [path, stub] of [ [OUTPUT_PATH, "{}\n"], + [ACCESS_OUTPUT_PATH, "{}\n"], [ "src/generated/skill-content.ts", "export const SKILL_FILES: [string, string][] = [];\n", @@ -62,6 +69,7 @@ const paths = publicTargetPaths(routes as unknown as RouteMap); const matched = new Set(); const references: Record = {}; const unresolved = new Map; reason: string }>(); +const apiMethods = new Map(); for (const { file, name } of analysis.commands) { const module = await import(pathToFileURL(resolve(file)).href); @@ -72,6 +80,10 @@ for (const { file, name } of analysis.commands) { 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 ?? {}) @@ -131,6 +143,17 @@ const catalog = Object.fromEntries( ]), ); 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/lib/command-access.ts b/packages/cli/script/lib/command-access.ts new file mode 100644 index 000000000..18b6e4033 --- /dev/null +++ b/packages/cli/script/lib/command-access.ts @@ -0,0 +1,138 @@ +/** + * 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 API 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) { + 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/source-api-usage.ts b/packages/cli/script/lib/source-api-usage.ts index cd2687cc6..4f9c3247e 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 { getAllEndpoints } 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,27 @@ export function analyzeSourceApiUsage( })), }; } + +/** Analyze the CLI's own sources (`src/`, without tests or generated files) against the API catalog. */ +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 operations = getAllEndpoints().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 68c111936..666c9e82f 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -127,6 +127,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 295b37d68..92d6fe061 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 963d47c5e..436d08d92 100644 --- a/packages/cli/src/commands/alert/issues/delete.ts +++ b/packages/cli/src/commands/alert/issues/delete.ts @@ -42,6 +42,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 dc2dba7b3..38d7ff7bd 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -194,6 +194,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 e114fc1d4..70b932795 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 30a803c10..ef751d468 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -40,6 +40,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 ec470a757..50570d81e 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -185,6 +185,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 da0a03d68..04d418852 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -1595,6 +1595,8 @@ export function resolveBinaryTtyOutput( } export const apiCommand = buildCommand({ + exposure: "exposed", + permission: "method", output: { human: formatApiResponse, jsonTransform: formatApiResponseJson }, docs: { brief: "Make an authenticated API request", 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/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 c26ec12d0..ffabd23f1 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 "../../types/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 48481b1c3..dc40ae863 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -620,6 +620,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 1cd6bd49e..19edebd6d 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -172,6 +172,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 5cf6570d0..9562db20d 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -370,6 +370,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 c2a172ab9..b7cd05489 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -54,6 +54,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 4e3ff0fd4..ac6e18e7a 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -109,6 +109,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 8c6b9192b..15f29bced 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 c1331dfd9..eb1063a40 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 803fbb3e3..0fd1acd31 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 158f67cda..9ea9d3889 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -192,6 +192,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 cce819e91..dd6caf738 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -525,6 +525,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/list.ts b/packages/cli/src/commands/event/list.ts index de2b9b9c5..ff9b24f1d 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 b50a5772e..e9ed94d43 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -1344,6 +1344,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 17b00df2f..8fec3928c 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -664,6 +664,7 @@ function buildResultHint(rowCount: number, nav: string): string | undefined { // --------------------------------------------------------------------------- export const exploreCommand = buildListCommand("explore", { + exposure: "exposed", docs: { brief: "Query aggregate event data (Explore)", fullDescription: diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 6ef037995..dca9cded3 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -134,6 +134,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 d4f4fc294..35ceb290d 100644 --- a/packages/cli/src/commands/feedback/resolve.ts +++ b/packages/cli/src/commands/feedback/resolve.ts @@ -12,6 +12,7 @@ import { type FeedbackTarget, feedbackPositional } from "./positional.js"; import { updateFeedbackStatus } from "./utils.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 5c6ec4c98..94516963e 100644 --- a/packages/cli/src/commands/feedback/spam.ts +++ b/packages/cli/src/commands/feedback/spam.ts @@ -12,6 +12,7 @@ import { type FeedbackTarget, feedbackPositional } from "./positional.js"; import { updateFeedbackStatus } from "./utils.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 a17b77c4e..ab3d1bac5 100644 --- a/packages/cli/src/commands/feedback/unresolve.ts +++ b/packages/cli/src/commands/feedback/unresolve.ts @@ -12,6 +12,7 @@ import { type FeedbackTarget, feedbackPositional } from "./positional.js"; import { updateFeedbackStatus } from "./utils.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 92be7a08f..2f3920111 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/help.ts b/packages/cli/src/commands/help.ts index 56a63391d..f40705b01 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -22,6 +22,7 @@ import { } from "../lib/help.js"; export const helpCommand = buildCommand({ + exposure: "exposed", auth: false, docs: { brief: "Display help for a command", diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index d13379522..1a2d28069 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -110,6 +110,7 @@ function formatInfo(data: InfoStatus): string { } export const infoCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Print configuration and verify authentication", fullDescription: diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e65d2d32d..b6870a546 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -356,6 +356,7 @@ export const initCommand = buildCommand< [InitArguments], SentryContext >({ + exposure: "stdio-only", docs: { brief: "Initialize Sentry in your project (experimental)", customUsage: ["[] []"], 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 390fa9ee1..adccc116d 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 7994a96ae..4c195c8bd 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -1634,6 +1634,7 @@ const issueListOutput: OutputConfig = { // --------------------------------------------------------------------------- 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 414a518cf..4592b9f9c 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -37,6 +37,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 4af606301..6a8c025cc 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -233,6 +233,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 09762f320..a32124e89 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -390,6 +390,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 b199b854d..8f935140c 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1011,6 +1011,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 d5685735c..9f7a457f7 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -764,6 +764,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 4ca2105de..49d5a5041 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -62,6 +62,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 64a047de8..69440951c 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -435,6 +435,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 95a165eca..264b87215 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 7fd2c49dd..9c55e0155 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 fc6003e91..8768251ee 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -852,6 +852,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 8e11951e3..e86f0c439 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -347,6 +347,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 c41f472d3..4c8e5589c 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -47,6 +47,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 a089e4985..0fb9ca979 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -92,6 +92,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 5fa4b89a3..6d850a928 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -92,6 +92,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 3456c6f06..ca4dd651a 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -99,6 +99,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 590466039..81ec98bfd 100644 --- a/packages/cli/src/commands/release/deploys.ts +++ b/packages/cli/src/commands/release/deploys.ts @@ -32,6 +32,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 6c28ceb9f..5e437f60d 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -66,6 +66,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 d39030a47..d3eecc03b 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -652,6 +652,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 52c6f4d0a..0bc976250 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -46,6 +46,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 46634fc06..f5a298308 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -344,6 +344,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 e013019ea..793efece8 100644 --- a/packages/cli/src/commands/release/view.ts +++ b/packages/cli/src/commands/release/view.ts @@ -153,6 +153,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 045fb96c0..a14a18690 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -253,6 +253,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 88a50c630..08b5f2e96 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 360d3e16d..ec73132fd 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -439,6 +439,7 @@ type SchemaFlags = { }; export const schemaCommand = buildCommand({ + exposure: "exposed", auth: false, docs: { brief: "Browse the Sentry API 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 3739c5292..9e8f8f6f4 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -211,6 +211,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 cf9c076f4..3bb717732 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -598,6 +598,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/trace/list.ts b/packages/cli/src/commands/trace/list.ts index ef826c769..c3f3b0e55 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -195,6 +195,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 90f70254d..3fa81476c 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -436,6 +436,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 44f341ce5..f93f0b220 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -214,6 +214,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 df73b80bf..86aca76a7 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -54,6 +54,7 @@ type WasmSplitResult = { }; export const wasmSplitCommand = buildCommand({ + exposure: "stdio-only", docs: { brief: "Add build ids to WebAssembly modules and split out debug data", 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 cd88511e0..912584bae 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; }; // --------------------------------------------------------------------------- @@ -979,6 +993,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 schema 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 7b6f84381..0ddf46dcf 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, @@ -74,6 +84,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 */ @@ -136,6 +150,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 */ @@ -147,6 +165,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 */ @@ -279,6 +302,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. * @@ -300,6 +329,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 } + : {}), })); } @@ -309,7 +341,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 jsonFields = cmd.__jsonSchema ? extractSchemaFields(cmd.__jsonSchema) : undefined; @@ -334,6 +372,12 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ), jsonFields: jsonFields?.length ? 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 a5b1e5c82..42007988f 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 { @@ -685,6 +687,8 @@ export function buildOrgListCommand( routeName: string, ): Command { return buildListCommand(routeName, { + // Org-scoped API listings: nothing here needs the user's machine. + exposure: "exposed", docs, output: { human: (result: ListResult) => formatListHuman(result, config), diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index 753f9ee74..f2efa43f2 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, @@ -322,6 +323,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..64463a6fd --- /dev/null +++ b/packages/cli/test/script/command-access.test.ts @@ -0,0 +1,153 @@ +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("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: [], + }); }); }); From 60447be7efe1e82fa9889fd0c3602473824ba5ac Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:18:48 +0000 Subject: [PATCH 2/3] fix(cli): Reject permission "method" on commands with fixed non-API writes --- packages/cli/script/lib/command-access.ts | 6 +++++- packages/cli/test/script/command-access.test.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/script/lib/command-access.ts b/packages/cli/script/lib/command-access.ts index 18b6e4033..a72975706 100644 --- a/packages/cli/script/lib/command-access.ts +++ b/packages/cli/script/lib/command-access.ts @@ -7,7 +7,7 @@ * - 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 API requests whose class each call's + * - `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. @@ -116,6 +116,10 @@ export function commandAccessProblems( 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) { diff --git a/packages/cli/test/script/command-access.test.ts b/packages/cli/test/script/command-access.test.ts index 64463a6fd..5427caa55 100644 --- a/packages/cli/test/script/command-access.test.ts +++ b/packages/cli/test/script/command-access.test.ts @@ -106,6 +106,9 @@ describe("command access check", () => { '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`", ]); From c53ef8d4cd63e820154c7b0e5c5f3d839d340762 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:14:45 +0000 Subject: [PATCH 3/3] fix(cli): Keep the command-api worker off the generated schema `analyzeCliSources()` imported `src/lib/api-schema.ts`, which imports `api-schema.json`. The command-api analysis worker imports the same module when `script/generate.ts` starts, before the schema step has written that file, so fresh checkouts failed with ERR_MODULE_NOT_FOUND (`@sentry/mcp-server` and CLI asset catalog jobs). Read the schema from disk when the function runs instead, as the worker does. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/script/lib/source-api-usage.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/script/lib/source-api-usage.ts b/packages/cli/script/lib/source-api-usage.ts index 4f9c3247e..a9f6dee34 100644 --- a/packages/cli/script/lib/source-api-usage.ts +++ b/packages/cli/script/lib/source-api-usage.ts @@ -6,7 +6,7 @@ import { type SourceApiOperation, } from "@sentry/api/source-analysis"; import { glob } from "tinyglobby"; -import { getAllEndpoints } from "../../src/lib/api-schema.js"; +import type { ApiEndpoint } from "../../src/lib/api-schema.js"; export type { SourceApiOperation } from "@sentry/api/source-analysis"; @@ -79,7 +79,14 @@ export function analyzeSourceApiUsage( }; } -/** Analyze the CLI's own sources (`src/`, without tests or generated files) against the API catalog. */ +/** + * 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"], @@ -89,7 +96,10 @@ export async function analyzeCliSources() { files.map(async (file) => [file, await readFile(file, "utf-8")] as const), ), ); - const operations = getAllEndpoints().flatMap((endpoint) => + const endpoints = JSON.parse( + await readFile("src/generated/api-schema.json", "utf-8"), + ) as ApiEndpoint[]; + const operations = endpoints.flatMap((endpoint) => endpoint.fn ? [ {