From 62083ee146f934ce2e04651ad594f4284893313c Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Tue, 28 Jul 2026 20:45:30 +0900 Subject: [PATCH] =?UTF-8?q?CRUD=E6=A8=A9=E9=99=90=E3=83=9D=E3=83=AA?= =?UTF-8?q?=E3=82=B7=E3=83=BC=E3=81=A8CLI=E5=BC=95=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E3=82=92=E6=95=B4=E7=90=86=E3=81=970.4.1=E3=81=B8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CRUD権限の解析と操作ごとのアクセス方針を整理し、新しい操作が追加された際に 未定義の権限で実行されない構造へ変更しました。あわせてCLI引数解析を独立した 処理へ分離し、未知・重複オプションや余分な引数を明示的な使用法エラーとして 扱うようにしました。 製品バージョンは`0.4.0`から`0.4.1`へ更新しました。 ## 変更内容 ### CRUD権限と操作ポリシー - 環境変数と`--allow`で共通利用するCRUD権限パーサーを追加 - 既定のREAD権限と対応する4権限の定義を一元化 - 全59操作のmutation分類と必要権限を明示的なポリシーとして定義 - 未知の操作を環境設定やBacklogクライアントの解決前に拒否 - ポリシー未定義の操作を暗黙のREADとして扱わず、設定エラーとして停止 ### CLI引数解析 - CLI引数解析を独立した純粋関数へ分離 - 未知のオプション、重複オプション、余分な位置引数を終了コード2で拒否 - `help`、`--version`、`tools list`、`trace`、`call`ごとの引数条件を明示 - 厳格な引数規則を`--help`へ追加 ### バージョンとトレーサビリティ - `package.json`、lock、製品情報、開発資料を`0.4.1`へ更新 - 操作対応表を再生成し、対象製品バージョンを更新 - CLIとruntimeのバージョン検証を更新 ## 動作確認 - `npm run trace:refresh`:59操作の対応表を生成 - `npm test`:41テスト成功 - `npm run smoke:node`:成功 --- docs/development.md | 2 +- docs/traceability/upstream-tool-mapping.json | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/smoke-node.mjs | 2 +- src/cli.ts | 95 +++++------- src/core/access-permissions.ts | 47 ++++-- src/core/catalog.ts | 144 ++++++++++++++---- src/core/cli-arguments.ts | 152 +++++++++++++++++++ src/core/run-operation.ts | 23 ++- src/product.ts | 2 +- tests/access-policy-and-rate-limit.test.mjs | 12 +- tests/node-cli.test.mjs | 20 ++- tests/node-runtime.test.mjs | 18 +++ 14 files changed, 407 insertions(+), 118 deletions(-) create mode 100644 src/core/cli-arguments.ts diff --git a/docs/development.md b/docs/development.md index f8e949d..a128e52 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Initial Design Record - checked date: 2026-07-22 -- repository version: `0.4.0` +- repository version: `0.4.1` - implementation maturity: beta standalone Node Core/CLI - split source: `backlog-api-skills` initial combined implementation diff --git a/docs/traceability/upstream-tool-mapping.json b/docs/traceability/upstream-tool-mapping.json index 1619c15..11af7c3 100644 --- a/docs/traceability/upstream-tool-mapping.json +++ b/docs/traceability/upstream-tool-mapping.json @@ -10,7 +10,7 @@ "target": { "repository": "backlog-api", "product": "backlog-api", - "version": "0.4.0", + "version": "0.4.1", "strategy": "published-handler-direct-invocation" }, "operations": [ diff --git a/package-lock.json b/package-lock.json index 0595ec5..4b3752f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "backlog-api", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backlog-api", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "backlog-js": "0.18.1", "backlog-mcp-server": "0.13.2" diff --git a/package.json b/package.json index afa9f73..1a43c56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "backlog-api", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", "bin": { diff --git a/scripts/smoke-node.mjs b/scripts/smoke-node.mjs index a2b750c..68236a7 100644 --- a/scripts/smoke-node.mjs +++ b/scripts/smoke-node.mjs @@ -13,7 +13,7 @@ for (const args of [["--version"], ["--help"], ["tools", "list"]]) { const runtime = await import("../bundle/backlog-api-runtime.mjs"); assert.equal(runtime.product.name, "backlog-api"); -assert.equal(runtime.product.version, "0.4.0"); +assert.equal(runtime.product.version, "0.4.1"); assert.equal(runtime.listOperations().length, 59); assert.equal( runtime.listOperations().find((operation) => operation.name === "get_rate_limit") diff --git a/src/cli.ts b/src/cli.ts index f556e01..819e706 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,11 +2,11 @@ import fs from "node:fs"; import { listOperations } from "./core/catalog.js"; -import type { - BacklogAccessEvent, - CrudPermission, - RunOperationOptions -} from "./core/contracts.js"; +import { + CliUsageError, + parseCliArguments +} from "./core/cli-arguments.js"; +import type { BacklogAccessEvent, RunOperationOptions } from "./core/contracts.js"; import { getMapping } from "./core/traceability.js"; import { runOperation } from "./core/run-operation.js"; import { formatBacklogAccessEvent } from "./core/verbose-format.js"; @@ -55,6 +55,8 @@ Call options: changed field names, pagination, and an exposed HTTP failure status. Content values, credentials, personal data, full arguments/results, and error text are omitted. + Each option may be specified once. Unknown options, duplicate options, and + extra positional arguments are usage errors. Input JSON: The input must be exactly one JSON object. Operation arguments are top-level @@ -132,51 +134,55 @@ main().catch((error) => { async function main(): Promise { const args = process.argv.slice(2); - if (args.length === 0 || args.includes("--help") || args[0] === "help") { + let command; + try { + command = parseCliArguments(args); + } catch (error) { + if (!(error instanceof CliUsageError)) { + throw error; + } + process.stderr.write(`${error.message}\n`); + process.exitCode = 2; + return; + } + + if (command.kind === "help") { process.stdout.write(HELP); return; } - if (args.includes("--version")) { + if (command.kind === "version") { process.stdout.write(`${product.version}\n`); return; } - if (args[0] === "tools" && args[1] === "list") { + if (command.kind === "tools-list") { writeJson({ schemaVersion: 1, product, operations: listOperations() }); return; } - if (args[0] === "trace") { + if (command.kind === "trace") { const mapping = getMapping(); - const operation = args[1]; writeJson( - operation + command.operation ? { ...mapping.upstream, - operation: mapping.operations.find((entry) => entry.operation === operation) + operation: mapping.operations.find( + (entry) => entry.operation === command.operation + ) } : mapping ); return; } - if (args[0] === "call" && args[1]) { - const inputPath = optionValue(args, "--input") ?? "-"; - let allowedPermissions; - try { - allowedPermissions = parseAllowedPermissions(optionValue(args, "--allow")); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 2; - return; - } - const input = JSON.parse(await readInput(inputPath)); + if (command.kind === "call") { + const input = JSON.parse(await readInput(command.inputPath)); const options: RunOperationOptions = { - dryRun: args.includes("--dry-run"), - confirmDestructive: args.includes("--confirm-destructive"), - allowedPermissions + dryRun: command.dryRun, + confirmDestructive: command.confirmDestructive, + allowedPermissions: command.allowedPermissions }; - if (args.includes("--verbose")) { + if (command.verbose) { options.onAccess = writeVerboseEvent; } - const result = await runOperation(args[1], input, options); + const result = await runOperation(command.operation, input, options); writeJson(result); if (!result.success) { process.exitCode = result.diagnostics.some((diagnostic) => @@ -185,39 +191,6 @@ async function main(): Promise { } return; } - - process.stderr.write("Unknown command. Use --help for usage.\n"); - process.exitCode = 2; -} - -function optionValue(args: string[], name: string): string | undefined { - const index = args.indexOf(name); - if (index < 0) { - return undefined; - } - const value = args[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`${name} requires a value.`); - } - return value; -} - -function parseAllowedPermissions(value: string | undefined): CrudPermission[] { - if (value === undefined) { - return ["READ"]; - } - const supported = new Set(["READ", "CREATE", "UPDATE", "DELETE"]); - const permissions = [...new Set(value.split(",").map((entry) => entry.trim().toUpperCase()))]; - const invalid = permissions.filter( - (permission) => !supported.has(permission as CrudPermission) - ); - if (invalid.length > 0) { - throw new Error( - `--allow contains unsupported permission(s): ${invalid.join(", ")}. ` + - "Use READ, CREATE, UPDATE, or DELETE." - ); - } - return permissions as CrudPermission[]; } async function readInput(inputPath: string): Promise { diff --git a/src/core/access-permissions.ts b/src/core/access-permissions.ts index f398816..5f102f9 100644 --- a/src/core/access-permissions.ts +++ b/src/core/access-permissions.ts @@ -2,33 +2,56 @@ import type { CrudPermission } from "./contracts.js"; export const BACKLOG_API_ALLOWED_PERMISSIONS = "BACKLOG_API_ALLOWED_PERMISSIONS"; -const DEFAULT_PERMISSIONS = Object.freeze(["READ"] satisfies CrudPermission[]); -const SUPPORTED_PERMISSIONS = new Set([ +export const DEFAULT_CRUD_PERMISSIONS = Object.freeze( + ["READ"] satisfies CrudPermission[] +); +export const CRUD_PERMISSIONS = Object.freeze([ "READ", "CREATE", "UPDATE", "DELETE" -]); +] satisfies CrudPermission[]); + +const SUPPORTED_PERMISSIONS = new Set(CRUD_PERMISSIONS); + +export class CrudPermissionParseError extends Error { + readonly invalidValues: readonly string[]; + + constructor(invalidValues: readonly string[]) { + super("CRUD permissions contain empty or unsupported values."); + this.name = "CrudPermissionParseError"; + this.invalidValues = invalidValues; + } +} + +export function parseCrudPermissions(value: string): readonly CrudPermission[] { + const entries = value.split(",").map((entry) => entry.trim().toUpperCase()); + const invalidValues = entries.filter( + (entry) => entry.length === 0 || !SUPPORTED_PERMISSIONS.has(entry as CrudPermission) + ); + if (invalidValues.length > 0) { + throw new CrudPermissionParseError(invalidValues); + } + return [...new Set(entries)] as CrudPermission[]; +} export function environmentAllowedPermissions( env: NodeJS.ProcessEnv ): readonly CrudPermission[] { const value = env[BACKLOG_API_ALLOWED_PERMISSIONS]; if (value === undefined) { - return DEFAULT_PERMISSIONS; + return DEFAULT_CRUD_PERMISSIONS; } - const entries = value.split(",").map((entry) => entry.trim().toUpperCase()); - if ( - entries.length === 0 || - entries.some((entry) => entry.length === 0) || - entries.some((entry) => !SUPPORTED_PERMISSIONS.has(entry as CrudPermission)) - ) { + try { + return parseCrudPermissions(value); + } catch (error) { + if (!(error instanceof CrudPermissionParseError)) { + throw error; + } throw new Error( `${BACKLOG_API_ALLOWED_PERMISSIONS} must be a comma-separated list containing only ` + "READ, CREATE, UPDATE, and DELETE." ); } - - return [...new Set(entries)] as CrudPermission[]; } diff --git a/src/core/catalog.ts b/src/core/catalog.ts index b042a13..b890b28 100644 --- a/src/core/catalog.ts +++ b/src/core/catalog.ts @@ -2,6 +2,83 @@ import { allTools } from "backlog-mcp-server/build/tools/tools.js"; import type { CrudPermission, MutationClass } from "./contracts.js"; import { createLocalToolset } from "./local-tools.js"; +interface OperationPolicy { + mutationClass: MutationClass; + requiredPermission: CrudPermission; +} + +const OPERATION_POLICIES = new Map([ + ...policyEntries([ + "count_issues", + "count_notifications", + "get_categories", + "get_custom_fields", + "get_document", + "get_document_tree", + "get_documents", + "get_git_repositories", + "get_git_repository", + "get_issue", + "get_issue_comments", + "get_issue_types", + "get_issues", + "get_myself", + "get_notifications", + "get_priorities", + "get_project", + "get_project_list", + "get_project_users", + "get_pull_request", + "get_pull_request_comments", + "get_pull_requests", + "get_pull_requests_count", + "get_rate_limit", + "get_resolutions", + "get_space", + "get_space_activities", + "get_user_recent_updates", + "get_user_stars_count", + "get_users", + "get_version_milestone_list", + "get_watching_list_count", + "get_watching_list_items", + "get_wiki", + "get_wiki_pages", + "get_wikis_count" + ], "read", "READ"), + ...policyEntries([ + "addDocument", + "add_issue", + "add_issue_comment", + "add_project", + "add_pull_request", + "add_pull_request_comment", + "add_version_milestone", + "add_watching", + "add_wiki" + ], "mutation", "CREATE"), + ...policyEntries([ + "mark_notification_as_read", + "mark_watching_as_read", + "update_issue", + "update_project", + "update_pull_request", + "update_pull_request_comment", + "update_version_milestone", + "update_watching", + "update_wiki" + ], "mutation", "UPDATE"), + ...policyEntries([ + "delete_issue", + "delete_project", + "delete_version", + "delete_watching" + ], "destructive", "DELETE"), + ...policyEntries([ + "reset_unread_notification_count" + ], "broad-mutation", "UPDATE") +]); + const fallbackTranslation = { t(_key: string, fallback: string): string { return fallback; @@ -30,16 +107,24 @@ export function createToolsets(backlog: object = metadataOnlyClient) { export function listOperations() { return createToolsets() - .flatMap((toolset) => toolset.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - toolset: toolset.name, - mutationClass: classifyMutation(tool.name), - requiredPermission: requiredPermission(tool.name) - }))) + .flatMap((toolset) => toolset.tools.map((tool) => { + const policy = requireOperationPolicy(tool.name); + return { + name: tool.name, + description: tool.description, + toolset: toolset.name, + ...policy + }; + })) .sort((left, right) => compareUtf16(left.name, right.name)); } +export function hasOperation(operationName: string): boolean { + return createToolsets().some((toolset) => + toolset.tools.some((tool) => tool.name === operationName) + ); +} + export function resolveTool(backlog: object, operationName: string) { for (const toolset of createToolsets(backlog)) { const tool = toolset.tools.find((candidate) => candidate.name === operationName); @@ -51,35 +136,30 @@ export function resolveTool(backlog: object, operationName: string) { } export function classifyMutation(operationName: string): MutationClass { - if (operationName.startsWith("delete_")) { - return "destructive"; - } - if (operationName === "reset_unread_notification_count") { - return "broad-mutation"; - } - if ( - operationName.startsWith("add_") || - operationName === "addDocument" || - operationName.startsWith("update_") || - operationName.startsWith("mark_") - ) { - return "mutation"; - } - return "read"; + return requireOperationPolicy(operationName).mutationClass; } export function requiredPermission(operationName: string): CrudPermission { - const mutationClass = classifyMutation(operationName); - if (mutationClass === "read") { - return "READ"; - } - if (mutationClass === "destructive") { - return "DELETE"; - } - if (operationName.startsWith("add_") || operationName === "addDocument") { - return "CREATE"; + return requireOperationPolicy(operationName).requiredPermission; +} + +function requireOperationPolicy(operationName: string): OperationPolicy { + const policy = OPERATION_POLICIES.get(operationName); + if (policy === undefined) { + throw new Error(`Operation ${operationName} has no declared access policy.`); } - return "UPDATE"; + return policy; +} + +function policyEntries( + operationNames: readonly string[], + mutationClass: MutationClass, + requiredPermission: CrudPermission +): Array<[string, OperationPolicy]> { + return operationNames.map((operationName) => [ + operationName, + { mutationClass, requiredPermission } + ]); } function compareUtf16(left: string, right: string): number { diff --git a/src/core/cli-arguments.ts b/src/core/cli-arguments.ts new file mode 100644 index 0000000..d44d1ad --- /dev/null +++ b/src/core/cli-arguments.ts @@ -0,0 +1,152 @@ +import { + CrudPermissionParseError, + DEFAULT_CRUD_PERMISSIONS, + parseCrudPermissions +} from "./access-permissions.js"; +import type { CrudPermission } from "./contracts.js"; + +export type CliCommand = + | { kind: "help" } + | { kind: "version" } + | { kind: "tools-list" } + | { kind: "trace"; operation?: string } + | { + kind: "call"; + operation: string; + inputPath: string; + allowedPermissions: readonly CrudPermission[]; + dryRun: boolean; + confirmDestructive: boolean; + verbose: boolean; + }; + +export class CliUsageError extends Error { + constructor(message: string) { + super(message); + this.name = "CliUsageError"; + } +} + +export function parseCliArguments(args: readonly string[]): CliCommand { + if (args.length === 0 || args.includes("--help")) { + return { kind: "help" }; + } + if (args[0] === "help") { + requireArgumentCount(args, 1, "help"); + return { kind: "help" }; + } + if (args[0] === "--version") { + requireArgumentCount(args, 1, "--version"); + return { kind: "version" }; + } + if (args[0] === "tools" && args[1] === "list") { + requireArgumentCount(args, 2, "tools list"); + return { kind: "tools-list" }; + } + if (args[0] === "trace") { + if (args.length > 2) { + throw new CliUsageError("trace accepts at most one operation name."); + } + const operation = args[1]; + if (operation?.startsWith("--")) { + throw new CliUsageError(`Unknown option for trace: ${operation}.`); + } + return { + kind: "trace", + ...(operation === undefined ? {} : { operation }) + }; + } + if (args[0] === "call") { + return parseCallArguments(args); + } + throw new CliUsageError("Unknown command. Use --help for usage."); +} + +function parseCallArguments(args: readonly string[]): CliCommand { + const operation = args[1]; + if (operation === undefined || operation.startsWith("--")) { + throw new CliUsageError("call requires an operation name."); + } + + let inputPath = "-"; + let allowedPermissions: readonly CrudPermission[] = DEFAULT_CRUD_PERMISSIONS; + let dryRun = false; + let confirmDestructive = false; + let verbose = false; + const seenOptions = new Set(); + + for (let index = 2; index < args.length; index += 1) { + const argument = args[index]!; + if (!argument.startsWith("--")) { + throw new CliUsageError(`Unexpected argument for call: ${argument}.`); + } + if (seenOptions.has(argument)) { + throw new CliUsageError(`Option ${argument} may be specified only once.`); + } + seenOptions.add(argument); + + if (argument === "--input" || argument === "--allow") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new CliUsageError(`${argument} requires a value.`); + } + index += 1; + if (argument === "--input") { + inputPath = value; + } else { + allowedPermissions = parseAllowedPermissions(value); + } + continue; + } + if (argument === "--dry-run") { + dryRun = true; + continue; + } + if (argument === "--confirm-destructive") { + confirmDestructive = true; + continue; + } + if (argument === "--verbose") { + verbose = true; + continue; + } + throw new CliUsageError(`Unknown option for call: ${argument}.`); + } + + return { + kind: "call", + operation, + inputPath, + allowedPermissions, + dryRun, + confirmDestructive, + verbose + }; +} + +function parseAllowedPermissions(value: string): readonly CrudPermission[] { + try { + return parseCrudPermissions(value); + } catch (error) { + if (!(error instanceof CrudPermissionParseError)) { + throw error; + } + const invalid = error.invalidValues + .map((entry) => entry.length === 0 ? "" : entry) + .join(", "); + throw new CliUsageError( + `--allow contains unsupported permission(s): ${invalid}. ` + + "Use READ, CREATE, UPDATE, or DELETE." + ); + } +} + +function requireArgumentCount( + args: readonly string[], + expected: number, + command: string +): void { + if (args.length !== expected) { + throw new CliUsageError(`${command} does not accept additional arguments.`); + } +} diff --git a/src/core/run-operation.ts b/src/core/run-operation.ts index cb2c6f4..1c7771b 100644 --- a/src/core/run-operation.ts +++ b/src/core/run-operation.ts @@ -1,10 +1,16 @@ import { parseBacklogAPIError } from "backlog-mcp-server/build/backlog/parseBacklogAPIError.js"; import { BACKLOG_API_ALLOWED_PERMISSIONS, + DEFAULT_CRUD_PERMISSIONS, environmentAllowedPermissions } from "./access-permissions.js"; import { createBacklogClientRegistry } from "./backlog-client-registry.js"; -import { classifyMutation, requiredPermission, resolveTool } from "./catalog.js"; +import { + classifyMutation, + hasOperation, + requiredPermission, + resolveTool +} from "./catalog.js"; import type { DiagnosticCode, OperationFailure, @@ -22,8 +28,17 @@ export async function runOperation( options: RunOperationOptions = {} ): Promise { const trace = getUpstreamTrace(operation); - const mutationClass = classifyMutation(operation); - const permission = requiredPermission(operation); + if (!hasOperation(operation)) { + return failure(operation, "UNKNOWN_OPERATION", `Unknown operation: ${operation}`, trace); + } + let mutationClass; + let permission; + try { + mutationClass = classifyMutation(operation); + permission = requiredPermission(operation); + } catch (error) { + return failure(operation, "CONFIGURATION_ERROR", errorMessage(error), trace); + } const env = options.env ?? process.env; let environmentPermissions; try { @@ -42,7 +57,7 @@ export async function runOperation( ); } - const callPermissions = options.allowedPermissions ?? ["READ"]; + const callPermissions = options.allowedPermissions ?? DEFAULT_CRUD_PERMISSIONS; if (!callPermissions.includes(permission)) { return failure( operation, diff --git a/src/product.ts b/src/product.ts index 95a97e9..c5c0e39 100644 --- a/src/product.ts +++ b/src/product.ts @@ -1,5 +1,5 @@ export const product = Object.freeze({ name: "backlog-api", - version: "0.4.0", + version: "0.4.1", upstream: "backlog-mcp-server@0.13.2" }); diff --git a/tests/access-policy-and-rate-limit.test.mjs b/tests/access-policy-and-rate-limit.test.mjs index 0198dd6..63a095d 100644 --- a/tests/access-policy-and-rate-limit.test.mjs +++ b/tests/access-policy-and-rate-limit.test.mjs @@ -4,7 +4,10 @@ import { createBacklogCapturingFetch, extractBacklogResponseMetadata } from "../dist/ts/core/backlog-access-context.js"; -import { environmentAllowedPermissions } from "../dist/ts/core/access-permissions.js"; +import { + environmentAllowedPermissions, + parseCrudPermissions +} from "../dist/ts/core/access-permissions.js"; import { runOperation } from "../dist/ts/core/run-operation.js"; import { observeBacklogClient } from "../dist/ts/core/verbose-client.js"; @@ -24,6 +27,13 @@ test("environment CRUD permissions default, normalize, and reject malformed valu } }); +test("environment and call-level permissions share one parser", () => { + assert.deepEqual(parseCrudPermissions(" read , CREATE,read "), ["READ", "CREATE"]); + for (const value of ["", "READ,,CREATE", "READ CREATE", "READ,WRITE"]) { + assert.throws(() => parseCrudPermissions(value), /empty or unsupported/); + } +}); + test("environment permissions are enforced before call permissions and client resolution", async () => { const registry = { resolveClient() { diff --git a/tests/node-cli.test.mjs b/tests/node-cli.test.mjs index 413a610..9155591 100644 --- a/tests/node-cli.test.mjs +++ b/tests/node-cli.test.mjs @@ -7,7 +7,7 @@ const CLI = "bundle/backlog-api.mjs"; test("CLI metadata commands do not require credentials", () => { const version = run(["--version"]); assert.equal(version.status, 0); - assert.equal(version.stdout, "0.4.0\n"); + assert.equal(version.stdout, "0.4.1\n"); assert.equal(version.stderr, ""); const help = run(["--help"]); @@ -24,6 +24,7 @@ test("CLI metadata commands do not require credentials", () => { assert.match(help.stdout, /READ is not added\s+implicitly/); assert.match(help.stdout, /requires its permission in both/); assert.match(help.stdout, /configuration errors detected before/); + assert.match(help.stdout, /Unknown options, duplicate options/); assert.doesNotMatch( help.stdout, /^\s*backlog-api call delete_issue .*--allow DELETE/m @@ -96,6 +97,23 @@ test("CLI reports invalid fields as a structured usage failure", () => { assert.equal(JSON.parse(result.stdout).diagnostics[0].code, "INVALID_FIELDS"); }); +test("CLI rejects unknown, duplicate, and extra arguments", () => { + const cases = [ + [["call", "get_issue", "--unknown"], /Unknown option for call/], + [["call", "get_issue", "--dry-run", "--dry-run"], /specified only once/], + [["call", "get_issue", "extra"], /Unexpected argument for call/], + [["tools", "list", "extra"], /does not accept additional arguments/], + [["trace", "get_issue", "extra"], /at most one operation name/] + ]; + + for (const [args, expectedError] of cases) { + const result = run(args); + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, expectedError); + } +}); + function run(args, input = "", extraEnv = {}) { const result = spawnSync("node", [CLI, ...args], { cwd: process.cwd(), diff --git a/tests/node-runtime.test.mjs b/tests/node-runtime.test.mjs index 856778e..263684c 100644 --- a/tests/node-runtime.test.mjs +++ b/tests/node-runtime.test.mjs @@ -57,6 +57,24 @@ test("mutation classification preserves unusual upstream names", () => { assert.equal(requiredPermission("mark_notification_as_read"), "UPDATE"); assert.equal(requiredPermission("reset_unread_notification_count"), "UPDATE"); assert.equal(requiredPermission("delete_project"), "DELETE"); + assert.throws( + () => classifyMutation("archive_issue"), + /has no declared access policy/ + ); +}); + +test("unknown operations fail before permission configuration or client resolution", async () => { + const result = await runOperation("archive_issue", {}, { + env: { BACKLOG_API_ALLOWED_PERMISSIONS: "READ,,UPDATE" }, + registry: { + resolveClient() { + throw new Error("must not resolve"); + } + } + }); + + assert.equal(result.success, false); + assert.equal(result.diagnostics[0].code, "UNKNOWN_OPERATION"); }); test("permission policy rejects operations before client resolution", async () => {