diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 232fd56f..2dd6ea70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [1.28.0] - 2026-09-20 + +### Added + +- **Agent security commands** — `bl agents security overview` (protection overview for the last 24 hours) and `bl agents security alerts` (alert list with risk-level, asset-type, status, vendor, pagination and sorting filters). Both call the per-workspace AgentStudio host and honor the shared `text` / `json` / `--quiet` / `--dry-run` contract. The host is derived from `--workspace-id`, or overridden by `--base-url` / `DASHSCOPE_BASE_URL` / `auth login --base-url` pointed at a workspace or pre-release origin (e.g. `https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`). + +### Internal + +- Add Agent security E2E coverage (help, missing-workspace usage error, dry-run host derivation and `--base-url` override, query-string filters, enum fast-fail) and generate the `bailian-cli` skill reference for the new `agents` group. + ## [1.27.0] - 2026-09-18 ### Added @@ -29,10 +39,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Simplified authentication** — Console login is now the recommended default and can create an ordinary API key when needed. Existing ordinary API keys and Token Plan subscription keys use the same `bl auth login --api-key ` command. - **Automatic API key validation and endpoint selection** — API keys are validated before being saved. The CLI automatically selects an available regional endpoint and applies the appropriate Token Plan configuration when applicable. -### Fixed - -- **Video task diagnostics** — Failed video tasks now expose the service error code, message, request ID, and scheduling time; downloads show the actual failure detail instead of only an incomplete status. - ## [1.25.0] - 2026-09-14 ### Added diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index deacad28..9da2dc10 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,16 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [1.28.0] - 2026-09-20 + +### 新增 + +- **Agent 安全命令** —— `bl agents security overview`(最近 24 小时的防护总览)与 `bl agents security alerts`(告警列表,支持风险等级、资产类型、状态、厂商、分页与排序等筛选)。两者均对接按 workspace 区分的 AgentStudio 域名,遵循统一的 `text` / `json` / `--quiet` / `--dry-run` 约定。域名默认由 `--workspace-id` 推导,也可通过 `--base-url` / `DASHSCOPE_BASE_URL` / `auth login --base-url` 指向某个 workspace 或预发源覆盖(例如 `https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`)。 + +### 内部 + +- 补充 Agent 安全 E2E 覆盖(help、缺 workspace 的 usage 错误、dry-run 域名推导与 `--base-url` 覆盖、query string 筛选、枚举快失败),并为新的 `agents` 组生成 `bailian-cli` 技能 reference。 + ## [1.27.0] - 2026-09-18 ### 新增 @@ -29,10 +39,6 @@ - **简化认证方式** —— 默认推荐使用控制台登录,需要时可自动创建普通 API Key。已有普通 API Key 和 Token Plan 订阅 Key 统一使用 `bl auth login --api-key ` 登录。 - **API Key 自动校验与地域识别** —— API Key 会在保存前进行校验,CLI 自动选择可用地域,并在适用时完成 Token Plan 配置。 -### 修复 - -- **视频任务诊断** —— 视频任务失败时展示服务端错误码、错误信息、请求 ID 和调度时间;下载失败时展示实际失败原因,不再只提示任务未完成。 - ## [1.25.0] - 2026-09-14 ### 新增 diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a4bce7c..9a1051df 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "1.27.0", + "version": "1.28.0", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index b69cec66..9a21bf5d 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -77,6 +77,8 @@ import { speechVocabularyDelete, fileUpload, consoleCall, + securityOverview, + securityAlerts, usageFree, usageFreetier, usageStats, @@ -349,6 +351,8 @@ export const commands: Record = { "speech vocabulary delete": speechVocabularyDelete, "file upload": fileUpload, "console call": consoleCall, + "agents security overview": securityOverview, + "agents security alerts": securityAlerts, "usage free": usageFree, "usage freetier": usageFreetier, "usage stats": usageStats, diff --git a/packages/commands/package.json b/packages/commands/package.json index eb4db0d5..cd1bfc0c 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "1.27.0", + "version": "1.28.0", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/commands/src/commands/security/alerts.ts b/packages/commands/src/commands/security/alerts.ts new file mode 100644 index 00000000..564deac0 --- /dev/null +++ b/packages/commands/src/commands/security/alerts.ts @@ -0,0 +1,212 @@ +import { + defineCommand, + detectOutputFormat, + securityAgentLogsEndpoint, + securityGet, + type FlagsDef, + type SecurityAlertList, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + SECURITY_UI, + WORKSPACE_FLAG, + renderAlert, + resolveSecurityHost, + setSecurityParam, +} from "./shared.ts"; + +const ASSET_TYPES = ["agent", "tool", "skill", "knowledge_base", "memory", "channel"] as const; + +const ALERTS_FLAGS = { + ...WORKSPACE_FLAG, + page: { + type: "number", + valueHint: "", + description: { "en-US": "Page number (default: 1)", "zh-CN": "页码(默认:1)" }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { "en-US": "Results per page (default: 20)", "zh-CN": "每页结果数(默认:20)" }, + }, + riskLevel: { + type: "string", + valueHint: "", + choices: ["high", "medium", "low"] as const, + description: { + "en-US": "Filter by risk level: high, medium, low", + "zh-CN": "按风险等级筛选:high、medium、low", + }, + }, + riskName: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by risk name", "zh-CN": "按风险名称筛选" }, + }, + status: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by handling status", "zh-CN": "按处理状态筛选" }, + }, + statusList: { + type: "array", + valueHint: "", + description: { + "en-US": "Filter by multiple statuses (repeatable)", + "zh-CN": "按多个状态筛选(可重复传入)", + }, + }, + appName: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by application name", "zh-CN": "按应用名称筛选" }, + }, + assetType: { + type: "string", + valueHint: "", + choices: ASSET_TYPES, + description: { + "en-US": `Filter by asset type: ${ASSET_TYPES.join(", ")}`, + "zh-CN": `按资产类型筛选:${ASSET_TYPES.join("、")}`, + }, + }, + vendor: { + type: "string", + valueHint: "", + description: { "en-US": "Filter by vendor", "zh-CN": "按厂商筛选" }, + }, + orderBy: { + type: "string", + valueHint: "", + description: { + "en-US": "Sort field (default: check_time)", + "zh-CN": "排序字段(默认:check_time)", + }, + }, + order: { + type: "string", + valueHint: "", + choices: ["asc", "desc"] as const, + description: { + "en-US": "Sort direction: asc, desc (default: desc)", + "zh-CN": "排序方向:asc、desc(默认:desc)", + }, + }, + lang: { + type: "string", + valueHint: "", + choices: ["zh", "en"] as const, + description: { "en-US": "Response language: zh, en", "zh-CN": "响应语言:zh、en" }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "List Agent security alerts", + "zh-CN": "列出 Agent 安全告警", + }, + auth: "apiKey", + usageArgs: "[flags]", + flags: ALERTS_FLAGS, + notes: [ + { + "en-US": "Auth: uses DashScope API Key (Bearer token).", + "zh-CN": "鉴权:使用 DashScope API Key(Bearer Token)。", + }, + { + "en-US": "`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.", + "zh-CN": "`--workspace-id` 可通过 BAILIAN_WORKSPACE_ID 环境变量或配置项 workspace_id 设置。", + }, + { + "en-US": + "Filters, pagination and sorting go in the query string; enum flags are validated before any request is sent.", + "zh-CN": "筛选、分页与排序参数走 query string;枚举类 flag 在发起请求前校验。", + }, + { + "en-US": + "AgentStudio host: derived from --workspace-id by default; point --base-url / DASHSCOPE_BASE_URL (or `auth login --base-url`) at a workspace or pre-release origin such as https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio to override it, and --workspace-id is then not required.", + "zh-CN": + "AgentStudio 域名:默认由 --workspace-id 推导;将 --base-url / DASHSCOPE_BASE_URL(或 `auth login --base-url`)指向某个 workspace 或预发源(例如 https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio)即可覆盖,此时无需 --workspace-id。", + }, + ], + exampleArgs: [ + { "en-US": "--workspace-id ws-xxx", "zh-CN": "--workspace-id ws-xxx" }, + { + "en-US": "--risk-level high --page-size 50", + "zh-CN": "--risk-level high --page-size 50", + }, + { + "en-US": '--asset-type agent --app-name "demo app" --output json', + "zh-CN": '--asset-type agent --app-name "测试应用0" --output json', + }, + { + "en-US": "--status-list unhandled --status-list handling", + "zh-CN": "--status-list unhandled --status-list handling", + }, + ], + async run(ctx) { + const { settings, flags, localize } = ctx; + const format = detectOutputFormat(settings.output); + const host = resolveSecurityHost(ctx); + + const params = new URLSearchParams(); + setSecurityParam(params, "current_page", flags.page); + setSecurityParam(params, "page_size", flags.pageSize); + setSecurityParam(params, "risk_level", flags.riskLevel); + setSecurityParam(params, "risk_name", flags.riskName); + setSecurityParam(params, "status", flags.status); + setSecurityParam(params, "status_list", flags.statusList); + setSecurityParam(params, "app_name", flags.appName); + setSecurityParam(params, "asset_type", flags.assetType); + setSecurityParam(params, "vendor", flags.vendor); + setSecurityParam(params, "order_by", flags.orderBy); + setSecurityParam(params, "order", flags.order); + setSecurityParam(params, "lang", flags.lang); + + const query = params.toString(); + const base = securityAgentLogsEndpoint(host); + const endpoint = query ? `${base}?${query}` : base; + + if (settings.dryRun) { + emitResult({ endpoint, method: "GET" }, format); + return; + } + + const data = await securityGet(ctx.client, endpoint); + const alerts = data?.data ?? []; + + // --quiet wins over the output format: emit a bare, pipe-friendly list of + // alert IDs even when output=json is configured, so ID-driven pipelines + // (`bl agents security alerts --quiet | xargs …`) keep working. + if (settings.quiet) { + for (const alert of alerts) emitBare(alert.alert_id); + return; + } + + if (format === "json") { + emitResult(data ?? { stats: null, data: [], next_page: null }, format); + return; + } + + const stats = data?.stats; + if (stats) { + emitBare( + `${localize(SECURITY_UI.total)}: ${stats.total ?? "-"} ` + + `${localize(SECURITY_UI.high)}: ${stats.high ?? "-"} ` + + `${localize(SECURITY_UI.medium)}: ${stats.medium ?? "-"} ` + + `${localize(SECURITY_UI.low)}: ${stats.low ?? "-"}\n`, + ); + } + + if (alerts.length === 0) { + emitBare(localize(SECURITY_UI.noAlerts)); + return; + } + + for (const alert of alerts) renderAlert(localize, alert); + + if (data?.next_page) { + emitBare(`${localize(SECURITY_UI.nextPageCursor)}: ${data.next_page}`); + } + }, +}); diff --git a/packages/commands/src/commands/security/overview.ts b/packages/commands/src/commands/security/overview.ts new file mode 100644 index 00000000..95fffa53 --- /dev/null +++ b/packages/commands/src/commands/security/overview.ts @@ -0,0 +1,114 @@ +import { + defineCommand, + detectOutputFormat, + securityGet, + securityOverviewEndpoint, + type FlagsDef, + type SecurityOverview, + type SecurityScanStat, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + CAPABILITY_LABELS, + PROTECTION_LABELS, + SCAN_CARDS, + SECURITY_UI, + WORKSPACE_FLAG, + renderToggles, + resolveSecurityHost, +} from "./shared.ts"; + +const OVERVIEW_FLAGS = { + ...WORKSPACE_FLAG, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "Show the Agent security protection overview (last 24 hours)", + "zh-CN": "查看 Agent 安全防护总览(最近 24 小时)", + }, + auth: "apiKey", + usageArgs: "[flags]", + flags: OVERVIEW_FLAGS, + notes: [ + { + "en-US": "Auth: uses DashScope API Key (Bearer token).", + "zh-CN": "鉴权:使用 DashScope API Key(Bearer Token)。", + }, + { + "en-US": "`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.", + "zh-CN": "`--workspace-id` 可通过 BAILIAN_WORKSPACE_ID 环境变量或配置项 workspace_id 设置。", + }, + { + "en-US": + "Fixed to the last 24 hours; the AgentStudio region is always cn-beijing and is not configurable.", + "zh-CN": "固定统计最近 24 小时;AgentStudio 地域固定为 cn-beijing,不可配置。", + }, + { + "en-US": + "AgentStudio host: derived from --workspace-id by default; point --base-url / DASHSCOPE_BASE_URL (or `auth login --base-url`) at a workspace or pre-release origin such as https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio to override it, and --workspace-id is then not required.", + "zh-CN": + "AgentStudio 域名:默认由 --workspace-id 推导;将 --base-url / DASHSCOPE_BASE_URL(或 `auth login --base-url`)指向某个 workspace 或预发源(例如 https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio)即可覆盖,此时无需 --workspace-id。", + }, + ], + exampleArgs: [ + { "en-US": "--workspace-id ws-xxx", "zh-CN": "--workspace-id ws-xxx" }, + { + "en-US": "--workspace-id ws-xxx --output json", + "zh-CN": "--workspace-id ws-xxx --output json", + }, + ], + async run(ctx) { + const { settings, localize } = ctx; + const format = detectOutputFormat(settings.output); + const endpoint = securityOverviewEndpoint(resolveSecurityHost(ctx)); + + if (settings.dryRun) { + emitResult({ endpoint, method: "GET" }, format); + return; + } + + const data = await securityGet(ctx.client, endpoint); + if (!data) { + if (format === "json") emitResult({}, format); + else emitBare(localize(SECURITY_UI.overviewUnavailable)); + return; + } + + if (format === "json") { + emitResult(data, format); + return; + } + + // Banner totals are client-side sums across the detection cards. Each card + // accepts the snake_case (REST) or camelCase (console-gateway) field. + const cards = SCAN_CARDS.map(({ label, keys }) => { + const stat = keys + .map((key) => data[key] as SecurityScanStat | null | undefined) + .find((value) => value !== undefined); + return { label: localize(label), stat: stat ?? null }; + }); + const sum = (pick: (stat: SecurityScanStat) => number | null): number => + cards.reduce((total, card) => total + (card.stat ? (pick(card.stat) ?? 0) : 0), 0); + + emitBare( + `${localize(SECURITY_UI.scannedLabel)}: ${sum((stat) => stat.scanned)} ` + + `${localize(SECURITY_UI.risksLabel)}: ${sum((stat) => stat.hit)}`, + ); + + renderToggles(localize, SECURITY_UI.capabilities, data.capabilities, CAPABILITY_LABELS); + renderToggles(localize, SECURITY_UI.protection, data.protection, PROTECTION_LABELS); + + emitBare(`\n${localize(SECURITY_UI.detections)}`); + for (const { label, stat } of cards) { + if (!stat) { + emitBare(` ${label} ${localize(SECURITY_UI.unavailable)}`); + } else { + emitBare( + ` ${label} ${localize(SECURITY_UI.hit)} ${stat.hit ?? "-"} / ` + + `${localize(SECURITY_UI.scanned)} ${stat.scanned ?? "-"}`, + ); + } + } + }, +}); diff --git a/packages/commands/src/commands/security/shared.ts b/packages/commands/src/commands/security/shared.ts new file mode 100644 index 00000000..96a74f49 --- /dev/null +++ b/packages/commands/src/commands/security/shared.ts @@ -0,0 +1,154 @@ +// Shared building blocks for the Agent security commands (bl agents security *). +// +// AgentStudio uses the same per-workspace host scheme as the knowledge admin +// plane, so the --workspace-id flag and its three-level resolver are reused +// from there rather than duplicated — keeping the "workspace required" error +// identical across both command groups. +import { + agentStudioHost, + isDashScopeGateway, + type LocalizedText, + type SecurityAlert, + type SecurityOverview, + type SecurityToggle, +} from "bailian-cli-core"; +import { emitBare } from "bailian-cli-runtime"; +import { resolveWorkspaceId, WORKSPACE_FLAG } from "../knowledge/shared.ts"; + +export { resolveWorkspaceId, WORKSPACE_FLAG }; + +/** + * Resolve the AgentStudio host these commands talk to. + * + * Default: derive the per-workspace production host from --workspace-id (or + * BAILIAN_WORKSPACE_ID / config). But when the model base URL has been pointed + * at a non-DashScope origin — an explicit --base-url / DASHSCOPE_BASE_URL for a + * pre-release, private, or mock deployment — honor it as the host override and + * skip the workspace requirement (that origin is already fully qualified). + */ +export function resolveSecurityHost(ctx: { + client: { baseUrl: string }; + flags: { workspaceId?: string }; + settings: { workspaceId?: string }; + identity: { binName: string }; +}): string { + const origin = ctx.client.baseUrl.replace(/\/+$/, ""); + if (origin && !isDashScopeGateway(origin)) return origin; + return agentStudioHost(resolveWorkspaceId(ctx)); +} + +/** Locale selector supplied by the command context (`ctx.localize`). */ +export type Localize = (text: LocalizedText) => string; + +// The API returns codes only — display names live here to match the console, +// localized to en-US / zh-CN per the resolved language. +export const CAPABILITY_LABELS: Record = { + agent_identity: { "en-US": "Agent identity issuance", "zh-CN": "Agent 身份签发" }, + content_safety: { "en-US": "Content safety", "zh-CN": "内容安全" }, + supply_chain_scan: { "en-US": "Supply-chain static scan", "zh-CN": "供应链静态扫描" }, + credential_isolation: { "en-US": "Credential isolation", "zh-CN": "凭证隔离" }, + session_lifecycle: { "en-US": "Session lifecycle governance", "zh-CN": "session 生命周期治理" }, +}; + +// Protection entries are keyed by asset type; the doc lists only the codes, so +// the display names are maintained here. +export const PROTECTION_LABELS: Record = { + flow_agent: { "en-US": "Flow Agent", "zh-CN": "Flow Agent" }, + managed_agent: { "en-US": "Managed Agents", "zh-CN": "Managed Agents" }, + knowledge_base: { "en-US": "RAG", "zh-CN": "RAG" }, + memory: { "en-US": "Memory", "zh-CN": "Memory" }, + mcp: { "en-US": "Store", "zh-CN": "Store" }, + external_agent: { "en-US": "BYOA hosting", "zh-CN": "BYOA 托管" }, +}; + +/** Detection cards summed into the overview banner: label + snake_case (REST) / camelCase (gateway) keys. */ +export const SCAN_CARDS: Array<{ label: LocalizedText; keys: Array }> = [ + { + label: { "en-US": "Content safety", "zh-CN": "内容安全" }, + keys: ["content_safety", "contentSafety"], + }, + { label: { "en-US": "File scan", "zh-CN": "文件扫描" }, keys: ["file_scan", "fileScan"] }, + { label: { "en-US": "Skill scan", "zh-CN": "技能扫描" }, keys: ["skill_scan", "skillScan"] }, +]; + +/** User-facing strings shared by the security commands, localized en-US / zh-CN. */ +export const SECURITY_UI = { + unavailable: { "en-US": "(unavailable)", "zh-CN": "(不可用)" }, + on: { "en-US": "on", "zh-CN": "开启" }, + off: { "en-US": "off", "zh-CN": "关闭" }, + scannedLabel: { "en-US": "Scanned", "zh-CN": "扫描" }, + risksLabel: { "en-US": "Risks", "zh-CN": "风险" }, + capabilities: { "en-US": "Capabilities", "zh-CN": "能力项" }, + protection: { "en-US": "Protection", "zh-CN": "防护项" }, + detections: { "en-US": "Detections", "zh-CN": "检测项" }, + overviewUnavailable: { "en-US": "Overview unavailable.", "zh-CN": "总览不可用。" }, + hit: { "en-US": "hit", "zh-CN": "命中" }, + scanned: { "en-US": "scanned", "zh-CN": "扫描" }, + total: { "en-US": "Total", "zh-CN": "总计" }, + high: { "en-US": "high", "zh-CN": "高" }, + medium: { "en-US": "medium", "zh-CN": "中" }, + low: { "en-US": "low", "zh-CN": "低" }, + noAlerts: { "en-US": "No alerts found.", "zh-CN": "未找到告警。" }, + nextPageCursor: { "en-US": "Next page cursor", "zh-CN": "下一页游标" }, + app: { "en-US": "app", "zh-CN": "应用" }, + asset: { "en-US": "asset", "zh-CN": "资产" }, + status: { "en-US": "status", "zh-CN": "状态" }, + source: { "en-US": "source", "zh-CN": "来源" }, + checked: { "en-US": "checked", "zh-CN": "检测时间" }, +} satisfies Record; + +/** Append a query param only when present; arrays append each item (repeatable). */ +export function setSecurityParam( + params: URLSearchParams, + key: string, + value: string | number | string[] | null | undefined, +): void { + if (value === undefined || value === null || value === "") return; + if (Array.isArray(value)) { + for (const entry of value) params.append(key, entry); + return; + } + params.set(key, String(value)); +} + +/** Render a capability / protection toggle group; codes map to localized display names. */ +export function renderToggles( + localize: Localize, + title: LocalizedText, + toggles: SecurityToggle[] | null | undefined, + labels: Record, +): void { + emitBare(`\n${localize(title)}`); + if (!toggles || toggles.length === 0) { + emitBare(` ${localize(SECURITY_UI.unavailable)}`); + return; + } + for (const toggle of toggles) { + const count = typeof toggle.count === "number" ? ` (${toggle.count})` : ""; + const state = localize(toggle.enabled ? SECURITY_UI.on : SECURITY_UI.off); + const label = labels[toggle.key] ? localize(labels[toggle.key]) : toggle.key; + emitBare(` ${state} ${label}${count}`); + } +} + +/** check_time / handle_time are millisecond timestamp strings, sometimes ISO 8601. */ +export function formatSecurityTime(value: string | null | undefined): string { + if (!value) return "-"; + const millis = /^\d+$/.test(value) ? Number(value) : Date.parse(value); + if (Number.isNaN(millis)) return value; + return new Date(millis).toISOString().replace("T", " ").slice(0, 19); +} + +/** Render one alert row in text mode; field labels are localized. */ +export function renderAlert(localize: Localize, alert: SecurityAlert): void { + const level = (alert.risk_level ?? "unknown").toUpperCase(); + emitBare(`[${level}] ${alert.risk_name ?? "-"} (${alert.alert_id})`); + emitBare( + ` ${localize(SECURITY_UI.app)}: ${alert.app_name ?? "-"} ${localize(SECURITY_UI.asset)}: ${alert.asset_name ?? "-"} (${alert.asset_type ?? "-"})`, + ); + emitBare( + ` ${localize(SECURITY_UI.status)}: ${alert.status ?? "-"} ${localize(SECURITY_UI.source)}: ${alert.source ?? "-"} ${localize(SECURITY_UI.checked)}: ${formatSecurityTime(alert.check_time)}`, + ); + if (alert.risk_desc) emitBare(` ${alert.risk_desc}`); + emitBare(""); +} diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index ea8a0d0b..e977b6e5 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -80,6 +80,8 @@ export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/ export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts"; export { default as fileUpload } from "./commands/file/upload.ts"; export { default as consoleCall } from "./commands/console/call.ts"; +export { default as securityOverview } from "./commands/security/overview.ts"; +export { default as securityAlerts } from "./commands/security/alerts.ts"; export { default as usageFree } from "./commands/usage/free.ts"; export { default as usageFreetier } from "./commands/usage/freetier.ts"; export { default as usageStats } from "./commands/usage/stats.ts"; diff --git a/packages/commands/tests/e2e/security.e2e.test.ts b/packages/commands/tests/e2e/security.e2e.test.ts new file mode 100644 index 00000000..fe98f41f --- /dev/null +++ b/packages/commands/tests/e2e/security.e2e.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isKbAdminE2EReady, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { SECURITY_ROUTES } from "./topic-routes.ts"; + +interface DryRunBody { + endpoint?: string; + method?: string; +} + +describe("e2e: agents security overview", () => { + test("--help 展示 --workspace-id", async () => { + const { stderr, exitCode } = await runCommandHelp(SECURITY_ROUTES, [ + "agents", + "security", + "overview", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--workspace-id/i); + }); + + test("缺 workspace 时报 USAGE (2)", async () => { + const { stderr, exitCode } = await runCommandE2e( + SECURITY_ROUTES, + ["agents", "security", "overview", "--api-key", "sk-fake", "--output", "json"], + { BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/workspace.*required/i); + }); + + test("--dry-run 由 workspace 推导 AgentStudio 域名,不发请求", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e( + SECURITY_ROUTES, + [ + "agents", + "security", + "overview", + "--dry-run", + "--workspace-id", + "ws_test", + "--output", + "json", + ], + { BAILIAN_CONFIG_DIR: "/tmp", DASHSCOPE_BASE_URL: "" }, + ); // isolate dev config base_url + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.method).toBe("GET"); + expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/); + expect(data.endpoint).toMatch(/api\/v1\/agentstudio\/security\/overview/); + }); + + test("--dry-run --base-url 覆盖为自定义 origin,无需 workspace", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "overview", + "--dry-run", + "--base-url", + "https://security.pre.example.com", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.method).toBe("GET"); + expect(data.endpoint).toBe( + "https://security.pre.example.com/api/v1/agentstudio/security/overview", + ); + }); +}); + +describe("e2e: agents security alerts", () => { + test("--help 展示筛选/分页/排序 flags", async () => { + const { stderr, exitCode } = await runCommandHelp(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--workspace-id/i); + expect(stderr).toMatch(/--risk-level/i); + expect(stderr).toMatch(/--page-size/i); + expect(stderr).toMatch(/--asset-type/i); + expect(stderr).toMatch(/--status-list/i); + expect(stderr).toMatch(/--order-by/i); + expect(stderr).toMatch(/--lang/i); + }); + + test("--dry-run 把筛选/分页参数落到 query string", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--dry-run", + "--workspace-id", + "ws_test", + "--risk-level", + "high", + "--page-size", + "50", + "--page", + "2", + "--order", + "asc", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.method).toBe("GET"); + expect(data.endpoint).toMatch(/api\/v1\/agentstudio\/security\/agent_logs/); + expect(data.endpoint).toMatch(/risk_level=high/); + expect(data.endpoint).toMatch(/page_size=50/); + expect(data.endpoint).toMatch(/current_page=2/); + expect(data.endpoint).toMatch(/order=asc/); + }); + + test("--status-list 可重复传参(数组展开为多个 query 值)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--dry-run", + "--workspace-id", + "ws_test", + "--status-list", + "unhandled", + "--status-list", + "handling", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const { endpoint } = parseStdoutJson(stdout); + expect(endpoint).toMatch(/status_list=unhandled/); + expect(endpoint).toMatch(/status_list=handling/); + }); + + test("中文过滤值以 UTF-8 编码进入 query", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--dry-run", + "--workspace-id", + "ws_test", + "--app-name", + "测试应用0", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const { endpoint } = parseStdoutJson(stdout); + expect(endpoint).toMatch(/app_name=/); + expect(decodeURIComponent(endpoint ?? "")).toContain("测试应用0"); + }); + + test("非法 --risk-level 报 USAGE (2) 且零请求", async () => { + const { exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--dry-run", + "--workspace-id", + "ws_test", + "--risk-level", + "NOT_A_LEVEL", + ]); + expect(exitCode).toBe(2); + }); + + test("非法 --asset-type 报 USAGE (2)", async () => { + const { exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--dry-run", + "--workspace-id", + "ws_test", + "--asset-type", + "NOT_A_TYPE", + ]); + expect(exitCode).toBe(2); + }); +}); + +// Live: exercises the real envelope unwrap + Bearer injection. AgentStudio may +// require a service-linked role the test account lacks (errorCode 12000092 → +// AUTH exit 3), which is a correct server-driven outcome, so both 0 and 3 pass. +describe.skipIf(!isKbAdminE2EReady())("e2e: agents security (live)", () => { + const workspaceId = process.env.BAILIAN_WORKSPACE_ID!; + + test("overview 返回解包后的 JSON 或权限错误", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "overview", + "--workspace-id", + workspaceId, + "--output", + "json", + ]); + expect([0, 3], stderr).toContain(exitCode); + if (exitCode === 0) { + const data = parseStdoutJson>(stdout); + expect(data).toBeTypeOf("object"); + } + }); + + test("alerts 返回列表 JSON 或权限错误", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SECURITY_ROUTES, [ + "agents", + "security", + "alerts", + "--workspace-id", + workspaceId, + "--page-size", + "5", + "--output", + "json", + ]); + expect([0, 3], stderr).toContain(exitCode); + if (exitCode === 0) { + const data = parseStdoutJson<{ data?: unknown[] }>(stdout); + expect(Array.isArray(data.data)).toBe(true); + } + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 0ecc30a9..b4c4fe3e 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -87,6 +87,11 @@ export const MCP_ROUTES: E2eRouteExports = { export const SEARCH_WEB_ROUTES: E2eRouteExports = { "search web": "searchWeb" }; +export const SECURITY_ROUTES: E2eRouteExports = { + "agents security overview": "securityOverview", + "agents security alerts": "securityAlerts", +}; + export const PIPELINE_ROUTES: E2eRouteExports = { "pipeline run": "pipelineRun", "pipeline validate": "pipelineValidate", diff --git a/packages/commands/tests/security-view.test.ts b/packages/commands/tests/security-view.test.ts new file mode 100644 index 00000000..73a2aecf --- /dev/null +++ b/packages/commands/tests/security-view.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import type { LocalizedText } from "bailian-cli-core"; +import overview from "../src/commands/security/overview.ts"; +import alerts from "../src/commands/security/alerts.ts"; + +// View-layer tests for the Agent security commands. They lock in two fixes: +// - P2-3: `--quiet` wins over `output=json` (bare alert IDs, not a JSON doc). +// - P3: text output is localized en-US / zh-CN instead of hardcoded. +// Envelope parsing and exit codes live in core/tests/security-envelope.test.ts. + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function captureStdout(): string[] { + const output: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + return output; +} + +/** Mirror the runtime translator: pick the requested locale's variant. */ +function localizeWith(language: "en-US" | "zh-CN") { + return (text: LocalizedText): string => (typeof text === "string" ? text : text[language]); +} + +/** Non-gateway baseUrl so resolveSecurityHost uses it verbatim (no workspace needed). */ +function mockClient(body: unknown) { + return { + baseUrl: "https://mock.security.test", + request: vi.fn().mockResolvedValue({ + text: async () => JSON.stringify(body), + headers: { get: () => "application/json" }, + }), + }; +} + +function makeCtx( + client: unknown, + language: "en-US" | "zh-CN", + settings: Record = {}, +) { + return { + client, + localize: localizeWith(language), + flags: {}, + identity: { binName: "bl" }, + settings: { dryRun: false, output: "text", quiet: false, ...settings }, + } as never; +} + +const OVERVIEW_BODY = { + capabilities: [{ key: "content_safety", enabled: true, count: 3 }], + protection: [{ key: "flow_agent", enabled: true, count: 10 }], + content_safety: { hit: 2, scanned: 100 }, + file_scan: { hit: 0, scanned: 5 }, + skill_scan: null, +}; + +const ALERTS_BODY = { + stats: { total: 1, high: 1, medium: 0, low: 0 }, + data: [ + { + alert_id: "a1", + risk_level: "high", + risk_name: "Risk", + app_name: "app", + asset_name: "ast", + asset_type: "agent", + status: "unhandled", + source: "src", + check_time: "1700000000000", + }, + ], + next_page: null, +}; + +async function renderOverview(language: "en-US" | "zh-CN"): Promise { + const output = captureStdout(); + await overview.run(makeCtx(mockClient(OVERVIEW_BODY), language)); + const rendered = output.join(""); + vi.restoreAllMocks(); + return rendered; +} + +async function renderAlerts( + language: "en-US" | "zh-CN", + settings: Record = {}, +): Promise { + const output = captureStdout(); + await alerts.run(makeCtx(mockClient(ALERTS_BODY), language, settings)); + const rendered = output.join(""); + vi.restoreAllMocks(); + return rendered; +} + +describe("security overview text output is localized (P3)", () => { + test("zh-CN and en-US render different titles and labels", async () => { + const zh = await renderOverview("zh-CN"); + const en = await renderOverview("en-US"); + + expect(zh).toContain("能力项"); + expect(zh).toContain("内容安全"); + expect(zh).toContain("检测项"); + expect(en).toContain("Capabilities"); + expect(en).toContain("Content safety"); + expect(en).toContain("Detections"); + // The core regression: the two locales must NOT be identical anymore. + expect(zh).not.toBe(en); + }); +}); + +describe("security alerts output (P2-3 + P3)", () => { + test("--quiet wins over output=json: bare alert IDs, not a JSON document", async () => { + const rendered = await renderAlerts("en-US", { quiet: true, output: "json" }); + expect(rendered).toBe("a1\n"); + expect(rendered).not.toContain("{"); + }); + + test("text output localizes stats and field labels per language", async () => { + const zh = await renderAlerts("zh-CN"); + const en = await renderAlerts("en-US"); + + expect(zh).toContain("总计"); + expect(zh).toContain("应用"); + expect(en).toContain("Total"); + expect(en).toContain("app:"); + expect(zh).not.toBe(en); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index df2e541d..1dfdd37b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "1.27.0", + "version": "1.28.0", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index d3c067ad..1090d9cb 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -120,6 +120,33 @@ export function knowledgeChatEndpoint(workspaceId: string): string { return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com/api/v2/apps/knowledge/chat`; } +// ---- Agent Security Center (AgentStudio, workspace-based host) ---- +// AgentStudio shares the per-workspace host scheme with the RAG admin plane +// below, not the shared DashScope gateway. The region is fixed to cn-beijing +// per the backend — a full cloud region, a different namespace from the CLI's +// region (cn / us / intl), so it is never derived from it. + +/** Per-workspace AgentStudio host (same shape as {@link ragEndpoint}'s host). */ +export function agentStudioHost(workspaceId: string): string { + return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com`; +} + +const SECURITY_PREFIX = "/api/v1/agentstudio/security"; + +// Security endpoints take an already-resolved AgentStudio host (origin) — +// normally agentStudioHost(workspaceId), or a --base-url override for +// pre-release / private / mock. See resolveSecurityHost in client/security.ts. + +/** GET protection overview — fixed to the last 24 hours, no request params. */ +export function securityOverviewEndpoint(host: string): string { + return `${host}${SECURITY_PREFIX}/overview`; +} + +/** GET alert list (agent_logs); filters / pagination go in the query string. */ +export function securityAgentLogsEndpoint(host: string): string { + return `${host}${SECURITY_PREFIX}/agent_logs`; +} + // ---- MCP Services (Streamable HTTP) ---- export function mcpWebSearchPath(): string { return "/api/v1/mcps/WebSearch/mcp"; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index ffbaea88..e969cde2 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -28,6 +28,9 @@ export { sandboxTemplatePath, sandboxTemplateBuildStatusPath, responsesPath, + agentStudioHost, + securityOverviewEndpoint, + securityAgentLogsEndpoint, speechRecognizePath, speechSynthesizePath, speechVocabularyPath, @@ -72,6 +75,7 @@ export { } from "./headers.ts"; export type { HttpDeps, RequestOpts } from "./http.ts"; export { request, requestJson } from "./http.ts"; +export { securityGet, parseSecurityBody, isDashScopeGateway } from "./security.ts"; export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts"; export { Client, diff --git a/packages/core/src/client/security.ts b/packages/core/src/client/security.ts new file mode 100644 index 00000000..57faa2e5 --- /dev/null +++ b/packages/core/src/client/security.ts @@ -0,0 +1,165 @@ +import { REGIONS } from "../config/schema.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import type { Client } from "./client.ts"; + +/** Documented AgentStudio error codes → actionable hint. */ +const SECURITY_ERROR_HINTS: Record = { + "12000090": "STS credentials unavailable — retry in a moment", + "12000091": "Service-linked role creation failed — retry", + "12000092": "Use the primary account to create the service-linked role", + "12000093": "Cloud security service is unavailable — retry later", + "12000094": "Alert query failed — treat as no data", +}; + +/** The public DashScope gateway origins — the default model base URLs. */ +const DASHSCOPE_GATEWAY_ORIGINS = new Set(Object.values(REGIONS)); + +/** + * Whether an origin is a public DashScope gateway. The security commands derive + * their per-workspace AgentStudio host by default, so a base URL that is just + * the DashScope gateway is the default and is ignored; any other origin + * (pre-release / private / mock) is treated as an explicit host override. + */ +export function isDashScopeGateway(origin: string): boolean { + return DASHSCOPE_GATEWAY_ORIGINS.has(origin.replace(/\/+$/, "")); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** First non-empty string among the candidates, else undefined (treats "" as absent). */ +function firstNonEmptyString(...candidates: unknown[]): string | undefined { + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + return undefined; +} + +function throwSecurityFailure( + errorCode: string | undefined, + errorMsg: string | undefined, + rawResponse: string, +): never { + const code = errorCode ?? "unknown"; + // When neither a code nor a message is present the envelope shape is + // unrecognised (e.g. a backend contract change) — surface a body snippet so + // the failure is diagnosable instead of an opaque "unknown - no message". + const bodySnippet = + errorCode === undefined && errorMsg === undefined + ? `\nUnexpected response body (truncated): ${rawResponse.slice(0, 800)}` + : ""; + throw new BailianError( + `Security API failed: ${code} - ${errorMsg ?? "no message"}${bodySnippet}`, + // Server-side failures pass through as GENERAL per the repo error contract + // (AGENTS.md §3): the CLI does not re-classify backend error codes. 12000092 + // is a service-linked-role permission problem that re-authenticating the API + // Key cannot fix, so it must NOT map to AUTH (which would prompt a re-login). + ExitCode.GENERAL, + SECURITY_ERROR_HINTS[code], + { rawResponse: rawResponse.slice(0, 500) }, + ); +} + +/** + * Parse an Agent Security Center response body and return the business payload. + * + * The backend serves these through the Zelda "DataV2" double-envelope — the same + * shape the console gateway returns (see console/models.ts unwrapResponse): + * + * { code, successResponse, requestId, + * data: { success, errorCode, errorMsg, + * DataV2: { ret: ["SUCCESS::…"], + * data: { success, failed, data: } } } } + * + * The payload lives at `data.DataV2.data.data`. The legacy flat envelope + * `{ success, data, errorCode, errorMsg }` is still accepted so any endpoint + * that has not migrated keeps working. A failed or unrecognized shape throws + * with the raw body attached (surfaced by --output json) for diagnosis. + */ +export function parseSecurityBody(raw: string, contentType?: string | null): T | null { + let body: unknown; + try { + body = JSON.parse(raw); + } catch { + throw new BailianError( + `Security API returned non-JSON response (${contentType ?? "unknown type"}).`, + ExitCode.GENERAL, + undefined, + { rawResponse: raw.slice(0, 500) }, + ); + } + + const root = asRecord(body); + const data = root ? asRecord(root.data) : undefined; + const dataV2 = data ? asRecord(data.DataV2) : undefined; + + // Zelda / DataV2 double-envelope (current backend contract). + if (dataV2) { + const inner = asRecord(dataV2.data); + const ret = Array.isArray(dataV2.ret) ? dataV2.ret.map((entry) => String(entry)) : []; + const retOk = ret.length === 0 || ret.some((line) => line.startsWith("SUCCESS")); + const errorCode = firstNonEmptyString(data?.errorCode, root?.errorCode); + const errorMsg = + firstNonEmptyString(data?.errorMsg, root?.errorMsg) ?? (retOk ? undefined : ret.join("; ")); + const failed = + root?.successResponse === false || + data?.success === false || + inner?.success === false || + inner?.failed === true || + !retOk || + errorCode !== undefined; + if (failed) throwSecurityFailure(errorCode, errorMsg, raw); + return ((inner ? inner.data : undefined) ?? null) as T | null; + } + + // Legacy flat envelope: { success, data, errorCode, errorMsg }. + if (root && "success" in root) { + if (!root.success) { + throwSecurityFailure( + firstNonEmptyString(root.errorCode), + firstNonEmptyString(root.errorMsg), + raw, + ); + } + return (root.data ?? null) as T | null; + } + + // Envelope-level failure with no DataV2 payload: an HTTP 200 body may still + // carry successResponse:false / data.success:false (or a bare errorCode) when + // the gateway reports failure without the DataV2 wrapper. Check these markers + // BEFORE the bare-payload fallback, otherwise a failed response is rendered as + // a zero-risk success (Scanned: 0 / Risks: 0, exit code 0). + if (root) { + const bareErrorCode = firstNonEmptyString(data?.errorCode, root.errorCode); + const bareErrorMsg = firstNonEmptyString(data?.errorMsg, root.errorMsg); + const bareFailed = + root.successResponse === false || data?.success === false || bareErrorCode !== undefined; + if (bareFailed) throwSecurityFailure(bareErrorCode, bareErrorMsg, raw); + } + + // Bare payload: the REST endpoint currently returns the business object + // directly, with no envelope. Treat the root object as the payload. + if (root) return root as T; + + // Not an object at all — surface the raw body so the contract change is visible. + throwSecurityFailure(undefined, undefined, raw); +} + +/** + * GET a Security Center endpoint and unwrap its envelope. + * + * `url` is an absolute AgentStudio URL (see securityOverviewEndpoint / + * securityAgentLogsEndpoint, or a --base-url override); the Client uses it + * verbatim and injects the Bearer token. Genuine HTTP errors (non-2xx) surface + * through the Client transport; only HTTP 200 bodies reach parseSecurityBody. + * Returns null when the server reports success with no payload. + */ +export async function securityGet(client: Client, url: string): Promise { + const response = await client.request({ path: url, method: "GET" }); + const raw = await response.text(); + return parseSecurityBody(raw, response.headers.get("content-type")); +} diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 0a19de9c..d090456a 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -238,6 +238,14 @@ export function credentialFlagDefs(cmd: { auth: AuthRequirement }): FlagsDef { export interface CommandContext { /** 静态产品身份(binName/version/npmPackage/clientName)。 */ identity: Identity; + /** + * Locale selector for user-facing output: picks the `en-US` / `zh-CN` variant + * of a {@link LocalizedText} per the resolved language. Supplied by the runtime + * (RunContext.localize). Commands use it to localize what they *print*; the + * localized command metadata (description/flags/notes) is rendered by the + * runtime's help output and does not need this. + */ + localize: (text: LocalizedText) => string; /** flag/env/file 解析后的有效配置面。 */ settings: Settings; /** 只含本命令声明的 flag;全局 flag 经 settings 读。 */ diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index d9db6930..77fb30f6 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -85,3 +85,4 @@ export type { UserProfileResponse, } from "./api.ts"; export type * from "./knowledge-admin.ts"; +export type * from "./security.ts"; diff --git a/packages/core/src/types/security.ts b/packages/core/src/types/security.ts new file mode 100644 index 00000000..23cff26c --- /dev/null +++ b/packages/core/src/types/security.ts @@ -0,0 +1,65 @@ +// Types for the Agent Security Center (AgentStudio). The server returns codes +// only — display names are maintained by the client (see commands/security). +// Kept separate from api.ts, mirroring knowledge-admin.ts. + +/** A capability / protection switch keyed by a stable code. */ +export interface SecurityToggle { + key: string; + enabled: boolean; + /** Protection entries carry an asset count; absent for some (e.g. external_agent). */ + count?: number | null; +} + +/** Detection card: `hit` is the headline number, `scanned` the total. */ +export interface SecurityScanStat { + hit: number | null; + scanned: number | null; +} + +/** Protection overview — fixed to the last 24 hours, no request params. */ +export interface SecurityOverview { + capabilities?: SecurityToggle[] | null; + protection?: SecurityToggle[] | null; + // Detection cards: the REST endpoint returns snake_case, the console-gateway + // (DataV2) shape returns camelCase. Accept both; the renderer picks whichever + // is present. + content_safety?: SecurityScanStat | null; + file_scan?: SecurityScanStat | null; + skill_scan?: SecurityScanStat | null; + contentSafety?: SecurityScanStat | null; + fileScan?: SecurityScanStat | null; + skillScan?: SecurityScanStat | null; +} + +export type SecurityRiskLevel = "high" | "medium" | "low"; + +/** A single alert row from agent_logs. */ +export interface SecurityAlert { + alert_id: string; + risk_level?: SecurityRiskLevel | null; + risk_name?: string | null; + risk_desc?: string | null; + asset_type?: string | null; + asset_name?: string | null; + app_id?: string | null; + app_name?: string | null; + agent_name?: string | null; + status?: string | null; + source?: string | null; + /** Millisecond timestamp string; some environments return ISO 8601 instead. */ + check_time?: string | null; + handle_time?: string | null; + vendor?: string | null; +} + +/** agent_logs list — cursor paginated; `next_page` is null on the last page. */ +export interface SecurityAlertList { + stats?: { + total?: number | null; + high?: number | null; + medium?: number | null; + low?: number | null; + } | null; + data?: SecurityAlert[] | null; + next_page?: string | number | null; +} diff --git a/packages/core/tests/security-envelope.test.ts b/packages/core/tests/security-envelope.test.ts new file mode 100644 index 00000000..fdc0f9ff --- /dev/null +++ b/packages/core/tests/security-envelope.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "vite-plus/test"; +import { parseSecurityBody } from "../src/client/security.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; + +// The backend serves Agent Security Center through the Zelda "DataV2" double +// envelope; the business payload lives at data.DataV2.data.data and the +// detection cards are camelCase. parseSecurityBody must unwrap it, still accept +// the legacy flat envelope, and turn failures into BailianError with the right +// exit code. + +function dataV2Envelope(payload: unknown): string { + return JSON.stringify({ + code: "200", + successResponse: true, + requestId: "req-1", + data: { + success: true, + errorCode: "", + errorMsg: "", + DataV2: { + ret: ["SUCCESS::接口调用成功"], + data: { success: true, failed: false, data: payload }, + }, + }, + }); +} + +function catchError(run: () => unknown): BailianError { + try { + run(); + } catch (error) { + return error as BailianError; + } + throw new Error("expected parseSecurityBody to throw"); +} + +test("unwraps the DataV2 double-envelope to the camelCase payload", () => { + const payload = { + contentSafety: { hit: 4, scanned: 253055 }, + fileScan: { hit: 52, scanned: 579 }, + skillScan: { hit: 2, scanned: 37 }, + capabilities: [{ key: "agent_identity", enabled: true }], + protection: [ + { key: "flow_agent", enabled: true, count: 1656 }, + { key: "external_agent", enabled: false }, + ], + }; + expect(parseSecurityBody(dataV2Envelope(payload))).toEqual(payload); +}); + +test("returns a bare payload (no envelope) as the REST endpoint sends", () => { + const bare = JSON.stringify({ + capabilities: [{ key: "agent_identity", enabled: true, count: null }], + protection: [{ key: "flow_agent", enabled: true, count: 1656 }], + content_safety: { hit: 4, scanned: 253561 }, + file_scan: { hit: 52, scanned: 579 }, + skill_scan: { hit: 2, scanned: 37 }, + }); + const result = parseSecurityBody>(bare); + expect(result?.content_safety).toEqual({ hit: 4, scanned: 253561 }); + expect(result?.protection).toEqual([{ key: "flow_agent", enabled: true, count: 1656 }]); +}); + +test("still accepts the legacy flat envelope", () => { + expect(parseSecurityBody<{ a: number }>('{"success":true,"data":{"a":1}}')).toEqual({ a: 1 }); +}); + +test("success with a null payload returns null, not an error", () => { + expect(parseSecurityBody(dataV2Envelope(null))).toBeNull(); +}); + +test("maps 12000092 (service-linked-role permission) to GENERAL, not AUTH", () => { + // A service-linked-role permission failure is a server-side error: switching + // API Keys cannot fix it, so it must pass through as GENERAL (AGENTS.md §3), + // never AUTH (which would wrongly tell the user to re-login). + const error = catchError(() => + parseSecurityBody('{"success":false,"errorCode":"12000092","errorMsg":"no permission"}'), + ); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain("12000092"); +}); + +test("surfaces a DataV2 failure errorCode as a GENERAL error", () => { + const body = JSON.stringify({ + code: "500", + successResponse: false, + data: { + success: false, + errorCode: "12000093", + errorMsg: "service down", + DataV2: { ret: ["FAIL::boom"], data: { success: false, failed: true } }, + }, + }); + const error = catchError(() => parseSecurityBody(body)); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain("12000093"); +}); + +test("HTTP 200 failure envelope without DataV2 is not treated as a bare payload", () => { + // successResponse:false + data.success:false with no DataV2 wrapper must throw, + // not fall through to the bare-payload branch and render as a zero-risk success + // (Scanned: 0 / Risks: 0, exit code 0). + const body = JSON.stringify({ + code: "200", + successResponse: false, + requestId: "req-2", + data: { success: false, errorCode: "12000094", errorMsg: "alert query failed" }, + }); + const error = catchError(() => parseSecurityBody(body)); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain("12000094"); +}); + +test("rejects a non-JSON body with the content type", () => { + const error = catchError(() => parseSecurityBody("gateway", "text/html")); + expect(error.message).toContain("non-JSON"); +}); diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 9b41e938..2927dbd0 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "1.27.0", + "version": "1.28.0", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index dbbfde79..0699ccad 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "1.27.0", + "version": "1.28.0", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 7d737eff..53ea8f50 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-cli/reference/agents.md b/skills/bailian-cli/reference/agents.md new file mode 100644 index 00000000..74b2009a --- /dev/null +++ b/skills/bailian-cli/reference/agents.md @@ -0,0 +1,103 @@ +# `bl agents` commands + +> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Authentication | Description | +| ----------------------------- | -------------- | ----------------------------------------------------------- | +| `bl agents security alerts` | API Key | List Agent security alerts | +| `bl agents security overview` | API Key | Show the Agent security protection overview (last 24 hours) | + +## Command details + +### `bl agents security alerts` + +| Field | Value | +| ------------------ | ----------------------------------- | +| **Name** | `agents security alerts` | +| **Description** | List Agent security alerts | +| **Authentication** | API Key | +| **Usage** | `bl agents security alerts [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 20) | +| `--risk-level ` | string | no | Filter by risk level: high, medium, low | +| `--risk-name ` | string | no | Filter by risk name | +| `--status ` | string | no | Filter by handling status | +| `--status-list ` | array | no | Filter by multiple statuses (repeatable) | +| `--app-name ` | string | no | Filter by application name | +| `--asset-type ` | string | no | Filter by asset type: agent, tool, skill, knowledge_base, memory, channel | +| `--vendor ` | string | no | Filter by vendor | +| `--order-by ` | string | no | Sort field (default: check_time) | +| `--order ` | string | no | Sort direction: asc, desc (default: desc) | +| `--lang ` | string | no | Response language: zh, en | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses DashScope API Key (Bearer token). +- `--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id. +- Filters, pagination and sorting go in the query string; enum flags are validated before any request is sent. +- AgentStudio host: derived from --workspace-id by default; point --base-url / DASHSCOPE_BASE_URL (or `auth login --base-url`) at a workspace or pre-release origin such as https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio to override it, and --workspace-id is then not required. + +#### Examples + +```bash +bl agents security alerts --workspace-id ws-xxx +``` + +```bash +bl agents security alerts --risk-level high --page-size 50 +``` + +```bash +bl agents security alerts --asset-type agent --app-name "demo app" --output json +``` + +```bash +bl agents security alerts --status-list unhandled --status-list handling +``` + +### `bl agents security overview` + +| Field | Value | +| ------------------ | ----------------------------------------------------------- | +| **Name** | `agents security overview` | +| **Description** | Show the Agent security protection overview (last 24 hours) | +| **Authentication** | API Key | +| **Usage** | `bl agents security overview [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | --------------------------------------------------------------- | +| `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Auth: uses DashScope API Key (Bearer token). +- `--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id. +- Fixed to the last 24 hours; the AgentStudio region is always cn-beijing and is not configurable. +- AgentStudio host: derived from --workspace-id by default; point --base-url / DASHSCOPE_BASE_URL (or `auth login --base-url`) at a workspace or pre-release origin such as https://.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio to override it, and --workspace-id is then not required. + +#### Examples + +```bash +bl agents security overview --workspace-id ws-xxx +``` + +```bash +bl agents security overview --workspace-id ws-xxx --output json +``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index af707cd9..30d49495 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -12,6 +12,8 @@ Use this index for the skill-scoped quick index and global flags. | Command | Authentication | Description | Detail | | -------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------ | | `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | +| `bl agents security alerts` | API Key | List Agent security alerts | [agents.md](agents.md) | +| `bl agents security overview` | API Key | Show the Agent security protection overview (last 24 hours) | [agents.md](agents.md) | | `bl alert create` | Console | Create a model alert rule from an alert template | [alert.md](alert.md) | | `bl alert delete` | Console | Delete model alert rules | [alert.md](alert.md) | | `bl alert disable` | Console | Disable a model alert rule (keeps the rule, stops notifications) | [alert.md](alert.md) | @@ -147,6 +149,7 @@ Use this index for the skill-scoped quick index and global flags. | Group | Commands | Reference | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | `advisor` | `recommend` | [advisor.md](advisor.md) | +| `agents` | `security alerts`, `security overview` | [agents.md](agents.md) | | `alert` | `create`, `delete`, `disable`, `enable`, `history`, `list`, `metrics`, `template create`, `template delete`, `template list`, `template update`, `update` | [alert.md](alert.md) | | `app` | `call`, `list` | [app.md](app.md) | | `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) | diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 940b72b9..95f7c179 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 48656917..c4290282 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 56098e80..430c4064 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index b9d0c4c9..44da3290 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 7908efc2..87a3510c 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-sandbox metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index 798aeabc..1da2facb 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "1.27.0" + version: "1.28.0" requires: bins: ["bl"] description: >-