Files
zengyouling.zyl 546c2d2eb2 fix(helpers): require user confirmation for batch permission/member remove
Address the P1 review finding on PR #1085: --members lets one call remove
up to 30 USER/DEPT/CONVERSATION/TAG members, where departments, chats,
and role groups can indirectly affect many more users, yet the remove
branches called the MCP tool right after argument parsing with Safety
confirmation=not_required.

- drive permission remove, doc permission remove, and wiki member remove
  now declare confirmation=user_required. DeclareLeafMetadata installs
  the ConfirmSafety gate automatically (deferred to the first
  deps.Caller.CallTool so flag validation still fails first), so an
  unconfirmed invocation exits with the typed confirmation_required
  error and performs zero MCP calls; --yes, an interactive yes, or
  --dry-run previews remain the supported paths.
- Pass framework confirmation errors through WrapErrorWithOperation
  verbatim (new apperrors.IsConfirmationRequired). Text classification
  misrouted them: command paths containing "permission" (drive/doc
  permission remove) were re-reported as AUTH_PERMISSION_DENIED while
  other paths (wiki member remove) lost their reason and degraded to
  UNCLASSIFIED.
- Tests: TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall
  covers all three entry points for both --members and legacy --users —
  unconfirmed rejects with zero MCP calls, --yes dispatches exactly one
  call with the complete precise arguments, --dry-run previews without
  calls. Existing remove tests inject root --yes for the assembly
  assertions; blank --users still fails validation before confirmation.
2026-08-23 17:55:33 +08:00

693 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
ExitSuccess = 0
ExitAPI = 1
ExitAuth = 2
ExitValidation = 3
ExitPermission = 4
ExitInternal = 5
)
const (
CodeAuthNotConfigured = "AUTH_NOT_CONFIGURED"
CodeAuthTokenExpired = "AUTH_TOKEN_EXPIRED"
CodeAuthPermission = "AUTH_PERMISSION_DENIED"
CodeNetworkTimeout = "NETWORK_TIMEOUT"
CodeNetworkUnreachable = "NETWORK_UNREACHABLE"
CodeLockTimeout = "LOCK_TIMEOUT"
CodeResourceNotFound = "RESOURCE_NOT_FOUND"
CodeTableNotFound = "TABLE_NOT_FOUND"
CodeSheetNotFound = "SHEET_NOT_FOUND"
CodeFieldNotFound = "FIELD_NOT_FOUND"
CodeRecordNotFound = "RECORD_NOT_FOUND"
CodeInvalidJSON = "INPUT_INVALID_JSON"
CodeInvalidPath = "INPUT_INVALID_PATH"
CodeInputTooLarge = "INPUT_TOO_LARGE"
CodeMissingParam = "INPUT_MISSING_PARAM"
CodeInvalidParam = "INPUT_INVALID_PARAM"
CodeFileNotFound = "INPUT_FILE_NOT_FOUND"
CodeContentTruncated = "CONTENT_TRUNCATED"
CodeMCPServerError = "MCP_SERVER_ERROR"
CodeMCPToolError = "MCP_TOOL_ERROR"
CodeUnclassified = "UNCLASSIFIED"
)
// CLIError is a user-friendly error with code, suggestion, and exit code.
// Supports traceability via the Operation field and error chain via Cause.
type CLIError struct {
Code string
Message string
Suggestion string
Operation string // the operation that failed (for traceability)
Cause error
}
func (e *CLIError) Error() string {
s := fmt.Sprintf("[%s] %s", e.Code, e.Message)
if e.Operation != "" {
s += fmt.Sprintf(" (operation: %s)", e.Operation)
}
if e.Suggestion != "" {
s += fmt.Sprintf("\n hint: %s", e.Suggestion)
}
return s
}
func (e *CLIError) Unwrap() error { return e.Cause }
func (e *CLIError) ExitCode() int {
switch e.Code {
case CodeAuthNotConfigured, CodeAuthTokenExpired:
return ExitAuth
case CodeAuthPermission:
return ExitPermission
case CodeMissingParam, CodeInvalidParam, CodeInvalidJSON, CodeInvalidPath, CodeInputTooLarge, CodeFileNotFound:
return ExitValidation
case CodeContentTruncated:
return ExitAPI
case CodeMCPServerError, CodeMCPToolError:
return ExitAPI
case CodeNetworkTimeout, CodeNetworkUnreachable:
return ExitAPI
case CodeResourceNotFound, CodeTableNotFound, CodeSheetNotFound, CodeFieldNotFound, CodeRecordNotFound:
return ExitAPI
case CodeLockTimeout, CodeUnclassified:
return ExitInternal
default:
return ExitInternal
}
}
// ToJSON returns a structured JSON representation for machine consumption.
func (e *CLIError) ToJSON() map[string]any {
errMap := map[string]any{
"code": e.Code,
"message": e.Message,
"exit_code": e.ExitCode(),
}
if e.Operation != "" {
errMap["operation"] = e.Operation
}
if e.Suggestion != "" {
errMap["suggestion"] = e.Suggestion
}
if e.Cause != nil {
errMap["cause"] = e.Cause.Error()
}
return map[string]any{"error": errMap}
}
// PATError represents a PAT authorization failure that should be passed through
// to stderr as raw JSON without any CLI-layer wrapping.
type PATError struct {
RawJSON string
}
func (e *PATError) Error() string { return e.RawJSON }
func (e *PATError) ExitCode() int { return ExitPermission }
func (e *PATError) RawStderr() string { return e.RawJSON }
// WrapError analyzes a raw error and wraps it with a friendly message.
// It classifies errors by pattern (network, auth, permission, resource,
// JSON parse, server error, file lock) and returns an appropriate CLIError.
func WrapError(err error) error {
return WrapErrorWithOperation(err, "")
}
// WrapErrorWithOperation wraps an error with operation context for traceability.
func WrapErrorWithOperation(err error, operation string) error {
if err == nil {
return nil
}
if _, ok := err.(*CLIError); ok {
return err
}
if _, ok := err.(*PATError); ok {
return err
}
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
if apperrors.IsConfirmationRequired(err) {
return err
}
msg := err.Error()
// File lock timeout (cross-process lock)
if errContainsAny(msg, "timeout acquiring data lock", "timeout acquiring token lock") {
return &CLIError{
Code: CodeLockTimeout,
Message: "Failed to acquire file lock",
Suggestion: "Another dws process may be running. Wait for it to complete or check for stale lock files.",
Operation: operation,
Cause: err,
}
}
// Network: DNS resolution failure
if errContainsAny(msg, "no such host", "dns") {
return &CLIError{
Code: CodeNetworkUnreachable,
Message: "DNS resolution failed",
Suggestion: "Check network connection and DNS settings",
Operation: operation,
Cause: err,
}
}
// Network: TLS handshake
if errContainsAny(msg, "tls handshake", "certificate", "x509") {
return &CLIError{
Code: CodeNetworkUnreachable,
Message: "TLS handshake failed",
Suggestion: "Check system clock and CA certificates; corporate proxy may require custom CA",
Operation: operation,
Cause: err,
}
}
// Network: connection refused / unreachable
if errContainsAny(msg, "connection refused", "dial tcp") {
return &CLIError{
Code: CodeNetworkUnreachable,
Message: "Cannot connect to MCP server",
Suggestion: "Check network connection or verify MCP server URL (dws auth status)",
Operation: operation,
Cause: err,
}
}
// Network: timeout
if errContainsAny(msg, "timeout", "deadline exceeded", "context deadline") {
return &CLIError{
Code: CodeNetworkTimeout,
Message: "Request timed out",
Suggestion: "Retry later, or increase timeout with --timeout",
Operation: operation,
Cause: err,
}
}
// Token verification failure
if errContainsAny(msg, "token验证失败", "Token验证失败", "USER_TOKEN_ILLEGAL") {
return &CLIError{
Code: CodeAuthTokenExpired,
Message: "Token 已过期或验证失败",
Suggestion: authExpiredSuggestion(),
Operation: operation,
Cause: err,
}
}
// API key expired (e.g. apiKeyExpired from backend/gateway)
if errContainsAny(msg, "apiKeyExpired", "api_key_expired", "ApiKeyExpired") {
return &CLIError{
Code: CodeAuthTokenExpired,
Message: "API Key 已过期",
Suggestion: authExpiredSuggestion(),
Operation: operation,
Cause: err,
}
}
// Not logged in
if errContainsAny(msg, "Missing service_id or access_key") {
return &CLIError{
Code: CodeAuthNotConfigured,
Message: "当前未登录",
Suggestion: notLoggedInSuggestion(),
Operation: operation,
Cause: err,
}
}
// Permission denied
if errContainsWord(msg, "403") || errContainsAny(msg, "forbidden", "permission") {
suggestion := "Verify your account has permission for this resource"
if errContainsAny(msg, "doc", "文档", "节点") {
suggestion = "Contact document owner for access, or check if removed from collaborators"
} else if errContainsAny(msg, "aitable", "table", "base", "record", "字段") {
suggestion = "Run: dws aitable base list"
} else if errContainsAny(msg, "chat", "群", "会话") {
suggestion = "Verify you are a group member and the group still exists"
}
return &CLIError{
Code: CodeAuthPermission,
Message: "Permission denied",
Suggestion: suggestion,
Operation: operation,
Cause: err,
}
}
// 服务端已生成结构化错误信息(含操作索引、回滚告知等),
// 直接透传,不进入下方的模式分类(避免用固定文案覆盖服务端原始信息)
if operation == "sheet/batch_update" {
return &CLIError{
Code: CodeMCPServerError,
Message: msg,
Operation: operation,
Cause: err,
}
}
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
// 透传服务端原始错误信息。
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
errMsg := msg
var body map[string]any
if json.Unmarshal([]byte(msg), &body) == nil {
errMsg = businessErrorDisplayMessage(body, msg)
}
return &CLIError{
Code: CodeMCPToolError,
Message: errMsg,
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
Operation: operation,
Cause: err,
}
}
// Resource not found
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
suggestion := "Check if the resource exists or if your account has permission"
code := CodeResourceNotFound
if errContainsAny(msg, "table", "sheet", "dentry", "record", "base") {
code = CodeTableNotFound
suggestion = "Run: dws aitable doc search to verify BASE_ID, then sheet list for tables"
}
if errContainsAny(msg, "群不存在", "已解散", "被移出") {
suggestion = "Group may have been disbanded or you were removed. Run: dws chat search"
}
if errContainsAny(msg, "文档", "doc", "节点") {
suggestion = "Document may have been deleted or moved"
}
if errContainsAny(msg, "MCP不存在", "PARAM_ERROR") {
return &CLIError{
Code: CodeMCPServerError,
Message: msg,
Suggestion: "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。",
Operation: operation,
Cause: err,
}
}
return &CLIError{
Code: code,
Message: "Requested resource not found",
Suggestion: suggestion,
Operation: operation,
Cause: err,
}
}
// JSON parse errors
if errContainsAny(msg, "JSON 解析失败", "invalid character", "unexpected end of JSON input", "json:", "parsing tools/call response") {
suggestion := "Check the JSON format of your input"
if errContainsAny(msg, "add_base_record", "update_records") {
suggestion = "Check --data JSON format, must be [{\"fields\":{...}}]"
}
if errContainsAny(msg, "fieldId", "字段") {
suggestion += "; aitable record cells key must be fieldId, not field name. Run: dws aitable field get"
}
return &CLIError{
Code: CodeInvalidJSON,
Message: "Invalid JSON format",
Suggestion: suggestion,
Operation: operation,
Cause: err,
}
}
// Server errors (5xx)
if errContainsAny(msg, "HTTP 5", "internal server error") || errContainsWord(msg, "500") || errContainsWord(msg, "502") || errContainsWord(msg, "503") {
return &CLIError{
Code: CodeMCPServerError,
Message: "MCP server internal error",
Suggestion: "Retry later. If persistent, contact admin",
Operation: operation,
Cause: err,
}
}
// Operation failed (catch-all for server-side "操作失败")
if errContainsAny(msg, "操作失败") {
suggestion := "Use --verbose for detailed logs"
if errContainsAny(msg, "字段", "field", "类型") {
suggestion = "Run: dws aitable field get to verify field type and fieldId"
}
return &CLIError{
Code: CodeMCPToolError,
Message: msg,
Suggestion: suggestion,
Operation: operation,
Cause: err,
}
}
// Generic tool/call failure
if errContainsAny(msg, "tool", "调用失败") {
return &CLIError{
Code: CodeMCPToolError,
Message: msg,
Suggestion: "Use --verbose for detailed logs",
Operation: operation,
Cause: err,
}
}
// Check for known business error text patterns
if suggestion := suggestForBusinessErrorText(msg); suggestion != "" {
return &CLIError{
Code: CodeMCPToolError,
Message: msg,
Suggestion: suggestion,
Operation: operation,
Cause: err,
}
}
return &CLIError{
Code: CodeUnclassified,
Message: msg,
Suggestion: "Use --verbose for detailed error logs",
Operation: operation,
Cause: err,
}
}
// IsAuthError returns true if the error is a CLIError with an auth-related code.
func IsAuthError(err error) bool {
if cliErr, ok := err.(*CLIError); ok {
return cliErr.Code == CodeAuthTokenExpired
}
return false
}
// suggestForBusinessErrorText returns a user-facing suggestion for known business
// error text patterns, or "" if no specific suggestion applies.
func suggestForBusinessErrorText(text string) string {
switch {
case strings.Contains(text, "搜索内容不能为空"):
return "请提供非空搜索关键词: dws doc search --query \"关键词\"\n 若需浏览最近文档: dws doc list"
case strings.Contains(text, "User has no permission to access this email"):
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
strings.Contains(text, "成员不存在"):
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
strings.Contains(text, "FORBIDDEN"):
return permissionDeniedSuggestion(text)
default:
return ""
}
}
// documentPermissionServerCodes are permission-denied codes that are
// demonstrably drive-specific (their identity carries the forbidden.* domain
// prefix). Only these (plus the document/wiki role-threshold message wording)
// justify the dws drive permission apply-* guidance. Generic code names such
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
// (get-self-setting) and event-subscription tools have been observed returning
// NO_PERMISSION, so keying guidance on it would mislead non-document products
// exactly the way this classification tries to avoid.
var documentPermissionServerCodes = map[string]bool{
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// documentPermissionErrorText reports whether a permission-denied message is
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
// threshold wording emitted by the member-management APIs or the document
// permission error codes embedded in the message text.
func documentPermissionErrorText(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
return strings.Contains(lower, "forbidden.no.auth") ||
strings.Contains(lower, "forbidden.accessdenied") ||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
}
// isDocumentPermissionError reports whether a permission-denied body is
// specific to document/wiki access, where applying for access via
// dws drive permission apply-* is the actionable next step.
func isDocumentPermissionError(body map[string]any) bool {
for _, key := range []string{"code", "errorCode", "server_error_code"} {
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
return true
}
}
return documentPermissionErrorText(businessErrorMessage(body))
}
// genericPermissionSuggestion is the product-neutral suggestion for
// permission-denied errors that are not specific to document/wiki access.
const genericPermissionSuggestion = "Verify your account has permission for this resource"
// permissionDeniedSuggestion picks between document apply-permission guidance
// and a product-neutral hint: only document/wiki-specific errors point at
// dws drive permission apply-*, so other products never get a misleading
// document-permission suggestion.
func permissionDeniedSuggestion(text string) string {
if documentPermissionErrorText(text) {
return permissionApplyGuidance
}
return genericPermissionSuggestion
}
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
// body: document/wiki-specific errors get the apply-permission guidance; other
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
// or fall back to the product-neutral hint.
func permissionDeniedSuggestionFor(body map[string]any) string {
if isDocumentPermissionError(body) {
return permissionApplyGuidance
}
if suggestion := suggestForBusinessError(body); suggestion != "" {
return suggestion
}
return genericPermissionSuggestion
}
// ClassifyToolResultContent checks a raw MCP tool result content map for
// DWS gateway auth errors and PAT permission error codes. This is used as the
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
// a typed error before its generic business-error classification.
//
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
// no-permission guidance.
func ClassifyToolResultContent(content map[string]any) error {
if _, ok := getDWSGatewayErrorCode(content); ok {
raw, _ := json.Marshal(content)
return &CLIError{
Code: CodeAuthTokenExpired,
Message: string(raw),
Suggestion: authExpiredSuggestion(),
}
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := content[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(content, code)}
}
}
// Permission-denied business errors: surface apply-permission guidance before
// the framework's generic rendering swallows it. Guidance is limited to
// document/wiki-specific signals (forbidden.* codes or role-threshold
// wording); other products keep the product-neutral suggestion.
if isNoPermissionError(content) {
suggestion := permissionDeniedSuggestionFor(content)
raw, _ := json.Marshal(content)
return &CLIError{
Code: CodeAuthPermission,
Message: businessErrorDisplayMessage(content, string(raw)),
Suggestion: suggestion,
}
}
return nil
}
// patNoPermissionCodes are PAT error codes that should be passed through.
var patNoPermissionCodes = map[string]bool{
"PAT_NO_PERMISSION": true,
"PAT_LOW_RISK_NO_PERMISSION": true,
"PAT_MEDIUM_RISK_NO_PERMISSION": true,
"PAT_HIGH_RISK_NO_PERMISSION": true,
}
// noPermissionServerCodes are business-level permission-denied codes (from the
// MCP server / gateway) that should surface apply-permission guidance instead of
// being swallowed by the framework's generic business-error rendering.
var noPermissionServerCodes = map[string]bool{
"NO_PERMISSION": true,
"FORBIDDEN": true,
"forbidden.no.auth": true,
"forbidden.accessDenied": true,
}
// permissionApplySteps lists the two-step commands to apply for document access.
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
// Returns a typed error for known gateway auth failures, PAT interceptions,
// and business-level errors embedded in HTTP-200 JSON bodies.
//
// Check order matters: DWS gateway > PAT permission > generic business error.
func ClassifyMCPResponseText(text string) error {
var body map[string]any
if json.Unmarshal([]byte(text), &body) != nil {
return nil
}
if _, ok := getDWSGatewayErrorCode(body); ok {
return &CLIError{
Code: CodeAuthTokenExpired,
Message: text,
Suggestion: authExpiredSuggestion(),
}
}
if isNotLoggedInError(body) {
return &CLIError{
Code: CodeAuthNotConfigured,
Message: "当前未登录",
Suggestion: notLoggedInSuggestion(),
}
}
for _, key := range []string{"code", "errorCode"} {
if code, ok := body[key].(string); ok && patNoPermissionCodes[code] {
return &PATError{RawJSON: cleanPATJSON(body, code)}
}
}
if isNoPermissionError(body) {
return &CLIError{
Code: CodeAuthPermission,
Message: text,
Suggestion: permissionDeniedSuggestionFor(body),
}
}
if isBusinessError(body) {
return &CLIError{
Code: CodeMCPToolError,
Message: businessErrorDisplayMessage(body, text),
Suggestion: suggestForBusinessError(body),
}
}
return nil
}
var patTopLevelStrip = map[string]bool{
"success": true, "code": true, "errorCode": true, "error_code": true,
"message": true, "error": true, "trace_id": true, "class": true,
}
func cleanPATJSON(body map[string]any, code string) string {
out := map[string]any{
"success": false,
"code": code,
}
if data, ok := body["data"]; ok {
out["data"] = stripClassFields(data)
} else {
fallback := map[string]any{}
for k, v := range body {
if !patTopLevelStrip[k] {
fallback[k] = v
}
}
if len(fallback) > 0 {
out["data"] = stripClassFields(fallback)
}
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
}
return string(b)
}
func stripClassFields(v any) any {
switch val := v.(type) {
case map[string]any:
clean := make(map[string]any, len(val))
for k, item := range val {
if k == "class" {
continue
}
clean[k] = stripClassFields(item)
}
return clean
case []any:
clean := make([]any, len(val))
for i, item := range val {
clean[i] = stripClassFields(item)
}
return clean
default:
return v
}
}
func errContainsAny(s string, substrs ...string) bool {
lower := strings.ToLower(s)
for _, sub := range substrs {
if strings.Contains(lower, strings.ToLower(sub)) {
return true
}
}
return false
}
func errContainsWord(s string, substr string) bool {
lower := strings.ToLower(s)
sub := strings.ToLower(substr)
for i := 0; i < len(lower); {
idx := strings.Index(lower[i:], sub)
if idx < 0 {
return false
}
pos := i + idx
leftOK := pos == 0 || !isAlnum(lower[pos-1])
rightOK := pos+len(sub) >= len(lower) || !isAlnum(lower[pos+len(sub)])
if leftOK && rightOK {
return true
}
i = pos + 1
}
return false
}
func isAlnum(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}