diff --git a/shortcuts/drive/drive_permission_get_setting.go b/shortcuts/drive/drive_permission_get_setting.go new file mode 100644 index 0000000000..3853420d82 --- /dev/null +++ b/shortcuts/drive/drive_permission_get_setting.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type drivePermissionGetSettingSpec struct { + Token string + Type string +} + +var drivePermissionGetSettingTypes = []string{ + "doc", "sheet", "file", "wiki", "bitable", "docx", + "mindnote", "minutes", "slides", "folder", +} + +var drivePermissionGetSettingURLPathToType = []struct { + Prefix string + Type string +}{ + {"/drive/folder/", "folder"}, + {"/docx/", "docx"}, + {"/doc/", "doc"}, + {"/sheets/", "sheet"}, + {"/base/", "bitable"}, + {"/bitable/", "bitable"}, + {"/wiki/", "wiki"}, + {"/file/", "file"}, + {"/mindnotes/", "mindnote"}, + {"/slides/", "slides"}, + {"/minutes/", "minutes"}, +} + +func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) { + rawToken := strings.TrimSpace(runtime.Str("token")) + explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type"))) + + if rawToken == "" { + return drivePermissionGetSettingSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--token is required", + ).WithParam("--token") + } + + if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) { + return drivePermissionGetSettingSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid --type %q: allowed values are %s", + explicitType, + strings.Join(drivePermissionGetSettingTypes, ", "), + ).WithParam("--type") + } + + if strings.Contains(rawToken, "://") { + ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken) + if !ok { + return drivePermissionGetSettingSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type", + rawToken, + ).WithParam("--token") + } + if explicitType != "" && explicitType != ref.Type { + return drivePermissionGetSettingSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type %q conflicts with URL path type %q; remove --type or use a matching value", + explicitType, + ref.Type, + ).WithParam("--type") + } + if err := validate.ResourceName(ref.Token, "--token"); err != nil { + return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil + } + + if explicitType == "" { + return drivePermissionGetSettingSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type is required when --token is a bare token (allowed: %s)", + strings.Join(drivePermissionGetSettingTypes, ", "), + ).WithParam("--type") + } + + if err := validate.ResourceName(rawToken, "--token"); err != nil { + return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil +} + +func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Hostname() == "" { + return common.ResourceRef{}, false + } + + for _, mapping := range drivePermissionGetSettingURLPathToType { + if !strings.HasPrefix(parsed.Path, mapping.Prefix) { + continue + } + token := parsed.Path[len(mapping.Prefix):] + token = strings.TrimRight(token, "/") + if idx := strings.IndexByte(token, '/'); idx >= 0 { + token = token[:idx] + } + token = strings.TrimSpace(token) + if token == "" { + return common.ResourceRef{}, false + } + return common.ResourceRef{Type: mapping.Type, Token: token}, true + } + + return common.ResourceRef{}, false +} + +func drivePermissionGetSettingTypeAllowed(docType string) bool { + for _, allowed := range drivePermissionGetSettingTypes { + if docType == allowed { + return true + } + } + return false +} + +func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string { + if runtime != nil && runtime.Config != nil { + if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" { + return u + } + } + return common.BuildResourceURL("", s.Type, s.Token) +} + +func (s drivePermissionGetSettingSpec) params() map[string]interface{} { + return map[string]interface{}{"type": s.Type} +} + +func (s drivePermissionGetSettingSpec) apiPath() string { + return drivePermissionPublicV2Path(s.Token) +} + +func drivePermissionPublicV2Path(token string) string { + return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token)) +} + +func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) { + permissionPublic := common.GetMap(data, "permission_public") + if permissionPublic == nil { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "drive permission get response missing data.permission_public", + ) + } + return permissionPublic, nil +} + +// DrivePermissionGetSetting queries permission_public settings for a Drive +// document, file, wiki node, or folder. +var DrivePermissionGetSetting = common.Shortcut{ + Service: "drive", + Command: "+permission-get-setting", + Description: "Get public access, sharing, collaborator management, security, and comment permission settings", + Risk: "read", + Scopes: []string{"docs:permission.setting:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true}, + {Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes}, + }, + Tips: []string{ + "--token accepts a Lark URL or bare token; pass --type when using a bare token.", + "Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDrivePermissionGetSettingSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDrivePermissionGetSettingSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("Get Drive permission settings"). + GET(spec.apiPath()). + Params(spec.params()) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDrivePermissionGetSettingSpec(runtime) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token)) + data, err := runtime.CallAPITyped( + "GET", + spec.apiPath(), + spec.params(), + nil, + ) + if err != nil { + return err + } + + permissionPublic, err := drivePermissionGetSettingPermissionPublic(data) + if err != nil { + return err + } + permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ") + if err != nil { + return errs.NewInternalError( + errs.SubtypeInvalidResponse, + "encode drive permission settings for pretty output", + ).WithCause(err) + } + + out := map[string]interface{}{"permission_public": permissionPublic} + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "Type: %s\n", spec.Type) + fmt.Fprintf(w, "Token: %s\n", spec.Token) + if url := spec.url(runtime); url != "" { + fmt.Fprintf(w, "URL: %s\n", url) + } + fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty) + }) + return nil + }, +} diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go new file mode 100644 index 0000000000..f1b1d3fca0 --- /dev/null +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "drive +permission-get-setting"} + cmd.Flags().String("token", "", "") + cmd.Flags().String("type", "", "") + if token != "" { + if err := cmd.Flags().Set("token", token); err != nil { + t.Fatalf("set --token: %v", err) + } + } + if docType != "" { + if err := cmd.Flags().Set("type", docType); err != nil { + t.Fatalf("set --type: %v", err) + } + } + return common.TestNewRuntimeContext(cmd, driveTestConfig()) +} + +func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + wantTok string + wantType string + }{ + { + name: "folder URL", + token: "https://example.feishu.cn/drive/folder/fldTok?from=share", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "docx URL", + token: "https://example.feishu.cn/docx/doxTok", + wantTok: "doxTok", + wantType: "docx", + }, + { + name: "file URL", + token: "https://example.feishu.cn/file/boxTok", + wantTok: "boxTok", + wantType: "file", + }, + { + name: "wiki URL", + token: "https://example.feishu.cn/wiki/wikTok", + wantTok: "wikTok", + wantType: "wiki", + }, + { + name: "minutes URL", + token: "https://example.feishu.cn/minutes/obTok", + wantTok: "obTok", + wantType: "minutes", + }, + { + name: "mindnotes URL", + token: "https://example.feishu.cn/mindnotes/mndTok", + wantTok: "mndTok", + wantType: "mindnote", + }, + { + name: "bare folder token", + token: " fldTok ", + docType: " folder ", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "bare file token", + token: "boxTok", + docType: "file", + wantTok: "boxTok", + wantType: "file", + }, + { + name: "bare wiki token", + token: "wikTok", + docType: "wiki", + wantTok: "wikTok", + wantType: "wiki", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType) + spec, err := readDrivePermissionGetSettingSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if spec.Token != tt.wantTok { + t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok) + } + if spec.Type != tt.wantType { + t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType) + } + }) + } +} + +func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + wantParam string + wantMessage string + }{ + { + name: "missing token", + wantParam: "--token", + wantMessage: "--token is required", + }, + { + name: "bare token without type", + token: "doxTok", + wantParam: "--type", + wantMessage: "--type is required", + }, + { + name: "unsupported URL", + token: "https://example.feishu.cn/calendar/calTok", + wantParam: "--token", + wantMessage: "unsupported --token URL", + }, + { + name: "URL type conflict", + token: "https://example.feishu.cn/docx/doxTok", + docType: "sheet", + wantParam: "--type", + wantMessage: "conflicts with URL path type", + }, + { + name: "invalid bare token", + token: "../bad", + docType: "folder", + wantParam: "--token", + wantMessage: "--token", + }, + { + name: "invalid type", + token: "doxTok", + docType: "comment", + wantParam: "--type", + wantMessage: "invalid --type", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType) + _, err := readDrivePermissionGetSettingSpec(runtime) + if err == nil { + t.Fatal("expected validation error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + if validationErr, ok := err.(*errs.ValidationError); ok { + if validationErr.Param != tt.wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam) + } + } else { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage) + } + }) + } +} + +func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + wantURL string + wantType string + }{ + { + name: "folder URL", + token: "https://example.feishu.cn/drive/folder/fldTok", + wantURL: "/open-apis/drive/v2/permissions/fldTok/public", + wantType: "folder", + }, + { + name: "bare folder token", + token: "fldTok", + docType: "folder", + wantURL: "/open-apis/drive/v2/permissions/fldTok/public", + wantType: "folder", + }, + { + name: "docx URL", + token: "https://example.feishu.cn/docx/doxTok", + wantURL: "/open-apis/drive/v2/permissions/doxTok/public", + wantType: "docx", + }, + { + name: "bare wiki token", + token: "wikTok", + docType: "wiki", + wantURL: "/open-apis/drive/v2/permissions/wikTok/public", + wantType: "wiki", + }, + { + name: "file URL", + token: "https://example.feishu.cn/file/boxTok", + wantURL: "/open-apis/drive/v2/permissions/boxTok/public", + wantType: "file", + }, + { + name: "minutes URL", + token: "https://example.feishu.cn/minutes/obTok", + wantURL: "/open-apis/drive/v2/permissions/obTok/public", + wantType: "minutes", + }, + { + name: "mindnotes URL", + token: "https://example.feishu.cn/mindnotes/mndTok", + wantURL: "/open-apis/drive/v2/permissions/mndTok/public", + wantType: "mindnote", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType) + dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + out := string(data) + for _, want := range []string{ + `"` + tt.wantURL + `"`, + `"GET"`, + `"type":"` + tt.wantType + `"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, `"folder_token"`) { + t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out) + } + }) + } +} + +func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "permission_public": map[string]interface{}{ + "link_share_entity": "closed", + "external_access_entity": "closed", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "manage_collaborator_entity": "collaborator_can_view", + "lock_switch": false, + "server_future_field": "preserved", + }, + }, + }, + }) + + err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{ + "+permission-get-setting", + "--token", "doxTok", + "--type", "docx", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + for _, key := range []string{"type", "token", "url"} { + if _, ok := data[key]; ok { + t.Fatalf("data[%s] = %#v, want field omitted", key, data[key]) + } + } + permissionPublic, _ := data["permission_public"].(map[string]interface{}) + if permissionPublic == nil { + t.Fatalf("permission_public missing in output: %#v", data) + } + for key, want := range map[string]interface{}{ + "link_share_entity": "closed", + "external_access_entity": "closed", + "security_entity": "anyone_can_view", + "comment_entity": "anyone_can_view", + "share_entity": "anyone", + "manage_collaborator_entity": "collaborator_can_view", + "lock_switch": false, + "server_future_field": "preserved", + } { + if permissionPublic[key] != want { + t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want) + } + } +} + +func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"unexpected": "response"}, + }, + }) + + err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{ + "+permission-get-setting", + "--token", "doxTok", + "--type", "docx", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("expected invalid response error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("problem = %#v, want internal/invalid_response", problem) + } + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String()) + } +} + +func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "permission_public": map[string]interface{}{ + "link_share_entity": "closed", + "server_future_field": "preserved", + }, + }, + }, + }) + + err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{ + "+permission-get-setting", + "--token", "doxTok", + "--type", "docx", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{ + "Permission settings:", + `"link_share_entity": "closed"`, + `"server_future_field": "preserved"`, + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("pretty output missing %q:\n%s", want, stdout.String()) + } + } +} + +func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) { + t.Parallel() + + if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) { + t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes) + } + if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) { + t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes) + } + for _, flag := range DrivePermissionGetSetting.Flags { + if flag.Name == "token" && !flag.Required { + t.Fatal("--token must be declared required") + } + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index a61a3f459c..f296919386 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -33,6 +33,7 @@ func Shortcuts() []common.Shortcut { DriveApplyPermission, DriveMemberAdd, DriveMemberList, + DrivePermissionGetSetting, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 2c38cb3807..dfe66ef6ca 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -40,6 +40,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+apply-permission", "+member-add", "+member-list", + "+permission-get-setting", "+secure-label-list", "+secure-label-update", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 43153a8656..b4785ad8ab 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-drive version: 1.0.0 -description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。" +description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。" metadata: requires: bins: ["lark-cli"] @@ -27,6 +27,7 @@ metadata: - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 - 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。 - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 +- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。 - 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。 - 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。 - 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。 @@ -120,6 +121,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' ### 权限能力入口 - 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。 +- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 - 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。 - 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。 @@ -164,6 +166,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 | | [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 | | [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 | +| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 | | [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 | | [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 | diff --git a/skills/lark-drive/references/lark-drive-permission-get-setting.md b/skills/lark-drive/references/lark-drive-permission-get-setting.md new file mode 100644 index 0000000000..debaa59ede --- /dev/null +++ b/skills/lark-drive/references/lark-drive-permission-get-setting.md @@ -0,0 +1,48 @@ +# drive +permission-get-setting(查询权限设置) + +本 skill 对应 shortcut:`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。 + +## 命令 + +```bash +# 通过 URL 自动推断 type +lark-cli drive +permission-get-setting \ + --token 'https://example.feishu.cn/drive/folder/' \ + --as user --format json + +# 通过 bare token 显式指定 type +lark-cli drive +permission-get-setting \ + --token '' \ + --type folder \ + --as user --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 | +| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 | +| `--dry-run` | 否 | 只打印请求,不调用 API。 | + +## 输出 + +JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "permission_public": {} + } +} +``` + +`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。 + +## 行为说明 + +- **身份支持**:`--as user` 和 `--as bot` 均可用。 +- **所需 scope**:`docs:permission.setting:read`。 +- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。 diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md index 231e1ae170..1693ed9a7f 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md @@ -15,6 +15,8 @@ lark-cli drive +inspect --url '' --as user --format json ``` +`drive +inspect` 支持 Drive folder,并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL,或直接使用 `drive +permission-get-setting --token ''`;传 bare folder token 时必须显式传 `--type folder`。 + `/wiki/space/` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。 ## 目标发现 @@ -61,14 +63,30 @@ lark-cli drive metas batch_query \ --as user --format json ``` -读取 public permission: +读取权限设置: ```bash -lark-cli drive permission.public get \ - --params '{"token":"","type":""}' \ +lark-cli drive +permission-get-setting \ + --token '' --type '' \ + --as user --format json +``` + +裸 folder token 必须显式传 `--type folder`: + +```bash +lark-cli drive +permission-get-setting \ + --token '' --type folder \ --as user --format json ``` +通过 URL 读取权限设置时可以省略 `--type`: + +```bash +lark-cli drive +permission-get-setting \ + --token '' \ + --as user --format json # replace $LARK_DRIVE_URL before running +``` + 按需读取直接协作者/授权成员列表: ```bash diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md index 16057832d6..119075d761 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md @@ -27,7 +27,7 @@ - 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。 - 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。 - 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。 -- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。 +- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。 - 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。 - 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。 - 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。 @@ -36,7 +36,7 @@ - 当摘要未展示全部风险对象时,必须明确“完整清单包含 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。 - 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。 - 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。 -- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。 +- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。 - 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。 ## Semantic Rendering @@ -75,7 +75,7 @@ | `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions | | `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions | | field absent / unsupported | `=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown | -| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing | +| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing | | `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing | ## 定位与治理动作 @@ -165,7 +165,7 @@ Evidence fields: 覆盖情况: - 用户提供目标:;成功解析: -- 成功读取文档公共访问和协作权限设置:;读取失败 / 不支持 / 无权限: +- 成功读取目标公共访问和协作权限设置:;读取失败 / 不支持 / 无权限: 逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单): @@ -233,7 +233,7 @@ URL: 覆盖情况: - 当前身份可见目标: -- 已成功检查文档公共访问和协作权限设置: +- 已成功检查目标公共访问和协作权限设置: - 读取失败 / 已删除 / 无权限: - 未覆盖能力: @@ -355,8 +355,8 @@ Agent 必须回复: - 字段变更: - (): -> - 跳过项: -- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置> -- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用> +- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置> +- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用> 请确认是否进入写入确认。 ``` @@ -407,8 +407,8 @@ Agent 必须回复: - 风险: - 字段变更: - : -> -- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置> -- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用> +- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置> +- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用> 请确认是否执行。 ``` @@ -419,6 +419,6 @@ Agent 必须回复: 已完成: 验证: 清单状态: -回滚材料:<文档公共访问和协作权限设置快照 / 不适用> +回滚材料:<目标公共访问和协作权限设置快照 / 不适用> 剩余限制: ``` diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md index 50d3fdf47d..ef1ee234e8 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md @@ -38,7 +38,7 @@ Risk / Structure: `R2` / `S2` - 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。 - 内容审查、过期内容判断或知识质量评分。 - backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。 -- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。 +- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`。 - 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。 - 未按范围确认的批量写入。 @@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2` | `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) | | `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) | | `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) | -| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` | +| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` | | `RISK_ASSESS` | 本文件的 `Risk Classification` | | `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) | | `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 | @@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2` | State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State | |-------|---------------|---------------|--------------------|---------------|------------| | `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` | -| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` | +| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;Drive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` | | `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` | -| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` | +| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` | | `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` | | `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` | | `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` | @@ -91,22 +91,23 @@ Risk / Structure: `R2` / `S2` | State | Allowed Command Families | Purpose | |-------|--------------------------|---------| -| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data | +| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap data;Drive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 | | `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 | | `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 | | `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata | | `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 | | `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 | +| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 | | `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 | | `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 | | `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs | -| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` | +| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` | | `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 | | `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 | | `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` | | `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` | | `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID | -| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 | +| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata,包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 | ## Command Patterns @@ -120,9 +121,9 @@ Risk / Structure: `R2` / `S2` 1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。 2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。 -3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。 +3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`、`folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。 4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。 -5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。 +5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。 6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。 Wiki space / node 发现: @@ -134,7 +135,7 @@ Wiki space / node 发现: Drive folder 发现: -1. `/drive/folder/` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。 +1. `/drive/folder/` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token --type folder` 读取该文件夹自身设置。 2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。 3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。 4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。 @@ -142,11 +143,11 @@ Drive folder 发现: ## Fact Read Rules 1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。 -2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。 +2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。 3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。 4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。 5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。 -6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。 +6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。 7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。 8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。 9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。 @@ -163,17 +164,17 @@ Drive folder 发现: - `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。 - `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。 -每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。 +每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。 `AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。 ## 写入规则 -- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。 +- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。 - 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。 -- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。 +- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`,URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入。 - 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。 -- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。 +- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。 - `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。 - `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。 - 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。 @@ -183,8 +184,8 @@ Drive folder 发现: - 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。 - 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。 - 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。 -- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。 -- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。 +- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。 +- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。 - 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。 - 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。 diff --git a/tests/cli_e2e/drive/drive_permission_get_setting_test.go b/tests/cli_e2e/drive/drive_permission_get_setting_test.go new file mode 100644 index 0000000000..7c7d32d1c2 --- /dev/null +++ b/tests/cli_e2e/drive/drive_permission_get_setting_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_PermissionGetSettingDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantURL string + wantType string + }{ + { + name: "bare folder token", + args: []string{ + "drive", "+permission-get-setting", + "--token", "fldE2E001", + "--type", "folder", + "--dry-run", + }, + wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public", + wantType: "folder", + }, + { + name: "folder URL", + args: []string{ + "drive", "+permission-get-setting", + "--token", "https://example.feishu.cn/drive/folder/fldE2E001?from=share", + "--dry-run", + }, + wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public", + wantType: "folder", + }, + { + name: "docx URL", + args: []string{ + "drive", "+permission-get-setting", + "--token", "https://example.feishu.cn/docx/doxE2E001", + "--dry-run", + }, + wantURL: "/open-apis/drive/v2/permissions/doxE2E001/public", + wantType: "docx", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out) + } + if gjson.Get(out, "data.folder_token").Exists() { + t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out) + } + }) + } +} + +func TestDrive_PermissionGetSettingWorkflow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + folderToken := CreateDriveFolder( + t, + t, + ctx, + "lark-cli-e2e-drive-permission-get-setting-"+clie2e.GenerateSuffix(), + "bot", + "", + ) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+permission-get-setting", + "--token", folderToken, + "--type", "folder", + "--format", "json", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr) + if strings.Contains(combinedOutput, "docs:permission.setting:read") || + strings.Contains(combinedOutput, "app scope not enabled") || + strings.Contains(combinedOutput, "missing required scope") || + strings.Contains(combinedOutput, "99991672") { + t.Skipf("skip drive permission setting workflow due to missing bot scope docs:permission.setting:read: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr)) + } + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + if !gjson.Get(result.Stdout, "data.permission_public").Exists() { + t.Fatalf("permission_public missing in output\nstdout:\n%s", result.Stdout) + } +}