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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ import type { SentryContext } from "../../context.js";
import { CommandOutput } from "../../lib/formatters/output.js";

export const myCommand = buildCommand({
exposure: "exposed",
docs: {
brief: "Short description",
fullDescription: "Detailed description",
Expand Down Expand Up @@ -221,6 +222,8 @@ export const myCommand = buildCommand({
- The wrapper auto-injects `--json` and `--fields` flags. Do NOT add your own `json` flag.
- A `jsonTransform` replaces the default `--fields` filtering, so it must apply its `fields` argument (`filterFieldsIfSet` from `lib/formatters/json.ts`, or per item for list envelopes). The `sentry-cli/json-transform-uses-fields` lint rule rejects one that never reads it ([#1808](https://github.com/getsentry/toolkit/issues/1808)). Drop a transform that only returns its input; the default path already filters.
- Do NOT use `stdout.write()` or `if (flags.json)` branching — the wrapper handles it.
- Every command declares an `exposure`, where agents may run it through the MCP: `exposed`, `stdio-only` (it reads or writes local files, uses the `cwd` or git, reads the local credential store, or needs a terminal), or `hidden` (session management, CLI maintenance, `local`, `monitor run`). The builder types require it. When unsure, use `stdio-only`.
- The permission class (read, write, destructive) comes from the Sentry API requests `generate:command-api` finds, and `destructive` for destructive commands. Declare `permission` only when `check:command-access` asks for it: requests outside the Sentry API (`event send`, `docs`), source the analysis can't follow, or `method` for `sentry api`. The check rejects any other declaration (`lib/command-access.ts`, [#1880](https://github.com/getsentry/toolkit/issues/1880)).

### Command File Structure

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ parallel list of operation names or command vocabulary. Build diagnostics report
raw or unpublished calls that the generated catalog cannot identify, so the
relationships describe discovered usage rather than exhaustive coverage.

The same step writes `src/generated/command-api-access.json`: whether each
command's Sentry API requests only read (GET, HEAD, OPTIONS) or also write.
It counts the requests outside the public catalog that `command-api.json`
leaves out. Introspection combines it with the command's `destructive` flag
and any declared `permission` into the command's permission class, and
`check:command-access` fails when a declaration contradicts the requests or
a command declares no exposure.

Introspection derives canonical command paths, registered aliases, and group
membership from the route tree. Groups collect their public commands' generated
references, so adding a command requires no separate group mapping. A related API
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@
"check:doc-examples": "pnpm tsx script/check-doc-examples.ts",
"check:installer-docs": "pnpm tsx script/check-installer-docs.ts",
"check:destructive": "pnpm tsx script/check-destructive-commands.ts",
"ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive"
"check:command-access": "pnpm tsx script/check-command-access.ts",
"ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive && pnpm run check:command-access"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.39.0",
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/script/check-command-access.ts
Original file line number Diff line number Diff line change
@@ -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<Command, CommandRequests>();
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;
}
26 changes: 2 additions & 24 deletions packages/cli/script/check-destructive-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/") &&
Expand Down
1 change: 1 addition & 0 deletions packages/cli/script/ensure-generated.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const GRAMMAR_OUTPUTS = [
const DOCS_OUTPUTS = [
"src/generated/output-schemas",
"src/generated/command-api.json",
"src/generated/command-api-access.json",
"src/generated/skill-content.ts",
"src/generated/completion-targets.json",
"src/generated/top-level-routes.json",
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/script/generate-command-api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/** Generate command/API relationships from source calls and the public route tree. */
/**
* Generate command/API relationships from source calls and the public route
* tree, and the class of each command's Sentry API requests
* (`lib/command-access.ts`).
*/

import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { routes } from "../src/app.js";
import type { ApiReference } from "../src/lib/api-schema.js";
import { type ApiAccess, apiAccess } from "../src/lib/command-access.js";
import {
isCommand,
publicTargetPaths,
Expand All @@ -14,6 +19,7 @@ import {
import { commandApiAnalysis } from "./lib/command-api-analysis.js";

const OUTPUT_PATH = "src/generated/command-api.json";
const ACCESS_OUTPUT_PATH = "src/generated/command-api-access.json";

/** Write which API operations each public command calls. */
export async function emit(): Promise<void> {
Expand All @@ -26,6 +32,7 @@ export async function emit(): Promise<void> {
string,
{ commands: Set<string>; reason: string }
>();
const apiMethods = new Map<string, (string | undefined)[]>();

for (const { file, name } of analysis.commands) {
const module = await import(pathToFileURL(resolve(file)).href);
Expand All @@ -36,6 +43,10 @@ export async function emit(): Promise<void> {
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 ?? {})
Expand Down Expand Up @@ -95,6 +106,17 @@ export async function emit(): Promise<void> {
]),
);
await writeFile(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`);
const apiAccessByCommand: Record<string, ApiAccess> = {};
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)`,
);
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/script/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ const TREE_STEPS: readonly Step[] = [
{ name: "skill", load: () => import("./generate-skill.js") },
{ name: "sdk", load: () => import("./generate-sdk.js") },
// Last: its emit waits for the worker `prepare` starts, and no step reads
// `command-api.json` back (only `sentry schema` does, at runtime).
// `command-api.json` or `command-api-access.json` back (only the CLI does,
// at runtime).
{
name: "command-api",
load: () => import("./generate-command-api.js"),
Expand Down
142 changes: 142 additions & 0 deletions packages/cli/script/lib/command-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Rules for `check:command-access`: every command declares where agents may
* run it, and its permission class agrees with the requests it sends.
*
* A command's class comes from its Sentry API requests (`apiAccess`), so a
* declaration is accepted only where those can't settle it:
* - it also sends a write method outside the Sentry API (DSN ingest, the docs
* service), and must say whether that writes;
* - the analysis can't follow its source, or can't read a request's method;
* - `method`, for a command without fixed requests whose class each call's
* HTTP method sets (`sentry api`).
* Hidden commands never run for agents, so they need no declaration, but a
* declaration they make is still checked.
*/

import {
apiAccess,
COMMAND_EXPOSURES,
isWriteMethod,
} from "../../src/lib/command-access.js";
import {
type Command,
isCommand,
isRouteMap,
type RouteMap,
} from "../../src/lib/introspect.js";

/** Requests the source analysis found for one command. */
export type CommandRequests = {
/** Every method found, including requests outside the Sentry API. */
methods: readonly string[];
/** Methods of its Sentry API requests; `undefined` when unknown. */
apiMethods: readonly (string | undefined)[];
};

/** Every command in the tree, hidden routes included, with its first path. */
export function allCommands(
routeMap: RouteMap,
prefix: readonly string[] = [],
found = new Map<Command, string>(),
): Map<Command, string> {
for (const entry of routeMap.getAllEntries()) {
const path = [...prefix, entry.name.original];
if (isRouteMap(entry.target)) {
allCommands(entry.target, path, found);
} else if (isCommand(entry.target) && !found.has(entry.target)) {
found.set(entry.target, `sentry ${path.join(" ")}`);
}
}
return found;
}

/**
* Problems with one command's exposure and permission. `requests` is
* undefined when the analysis found no source export for the command.
*/
export function commandAccessProblems(
command: Command,
requests: CommandRequests | undefined,
): string[] {
const problems: string[] = [];
const exposure = command.__exposure;
if (!(exposure && COMMAND_EXPOSURES.includes(exposure))) {
problems.push(
`declares no exposure (${COMMAND_EXPOSURES.join(", ")}); pass \`exposure\` to its builder`,
);
}
const declared = command.__permission;
if (command.__destructive) {
if (declared) {
problems.push(
`is destructive, which sets its class; drop \`permission: "${declared}"\``,
);
}
return problems;
}
const required = exposure !== "hidden";
if (!requests) {
if (!declared && required) {
problems.push(
"has no source export the API analysis can follow; declare `permission`",
);
}
return problems;
}

const api = apiAccess(requests.apiMethods);
const apiWrites = unique(
requests.apiMethods.filter(
(method): method is string =>
method !== undefined && isWriteMethod(method),
),
);
const otherWrites = unique(
requests.methods.filter(
(method) =>
isWriteMethod(method) && !requests.apiMethods.includes(method),
),
);
if (api === "write") {
if (declared) {
problems.push(
declared === "write"
? 'declares `permission: "write"`, which its API requests already give; drop it'
: `declares \`permission: "${declared}"\`, but it sends ${apiWrites.join("/")} Sentry API requests`,
);
}
} else if (api === "unknown") {
if (!declared && required) {
problems.push(
"sends a Sentry API request whose method the analysis can't read; declare `permission`",
);
}
} else if (declared === "method") {
if (api === "read") {
problems.push(
'declares `permission: "method"`, but its Sentry API requests are read-only',
);
} else if (otherWrites.length > 0) {
problems.push(
`declares \`permission: "method"\`, but it sends ${otherWrites.join("/")} requests outside the Sentry API; declare \`permission: "read"\` or \`"write"\``,
);
}
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
} 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();
}
1 change: 1 addition & 0 deletions packages/cli/script/lib/route-tree-stub-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
/** @type {ReadonlyArray<readonly [path: string, stub: string]>} */
export const ROUTE_TREE_STUBS = [
["src/generated/command-api.json", "{}\n"],
["src/generated/command-api-access.json", "{}\n"],
["src/generated/top-level-routes.json", "{}\n"],
[
"src/generated/skill-content.ts",
Expand Down
Loading
Loading