diff --git a/shortcuts/drive/drive_folder_permission_get.go b/shortcuts/drive/drive_folder_permission_get.go new file mode 100644 index 000000000..e5b5f34a5 --- /dev/null +++ b/shortcuts/drive/drive_folder_permission_get.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type driveFolderPermissionGetSpec struct { + FolderToken string +} + +func readDriveFolderPermissionGetSpec(runtime *common.RuntimeContext) (driveFolderPermissionGetSpec, error) { + rawURL := strings.TrimSpace(runtime.Str("url")) + rawToken := strings.TrimSpace(runtime.Str("folder-token")) + + if rawURL == "" && rawToken == "" { + return driveFolderPermissionGetSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "pass exactly one of --url or --folder-token", + ).WithParam("--url") + } + if rawURL != "" && rawToken != "" { + return driveFolderPermissionGetSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--url and --folder-token are mutually exclusive; pass only one folder locator", + ).WithParam("--url") + } + + if rawToken != "" { + if err := validate.ResourceName(rawToken, "--folder-token"); err != nil { + return driveFolderPermissionGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") + } + return driveFolderPermissionGetSpec{FolderToken: rawToken}, nil + } + + ref, ok := common.ParseResourceURL(rawURL) + if !ok { + return driveFolderPermissionGetSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported --url %q: pass a recognized Lark Drive folder URL such as https://example.feishu.cn/drive/folder/", + rawURL, + ).WithParam("--url") + } + if ref.Type != "folder" { + return driveFolderPermissionGetSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--url must point to a Drive folder; got resource type %q", + ref.Type, + ).WithParam("--url") + } + if err := validate.ResourceName(ref.Token, "--url"); err != nil { + return driveFolderPermissionGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--url") + } + return driveFolderPermissionGetSpec{FolderToken: ref.Token}, nil +} + +func (s driveFolderPermissionGetSpec) url(runtime *common.RuntimeContext) string { + if runtime != nil && runtime.Config != nil { + if u := common.BuildResourceURL(runtime.Config.Brand, "folder", s.FolderToken); u != "" { + return u + } + } + return common.BuildResourceURL("", "folder", s.FolderToken) +} + +func (s driveFolderPermissionGetSpec) params() map[string]interface{} { + return map[string]interface{}{"type": "folder"} +} + +func (s driveFolderPermissionGetSpec) apiPath() string { + return drivePermissionPublicV2Path(s.FolderToken) +} + +func drivePermissionPublicV2Path(token string) string { + return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token)) +} + +func (s driveFolderPermissionGetSpec) output(runtime *common.RuntimeContext, data map[string]interface{}) map[string]interface{} { + permissionPublic := interface{}(data) + if nestedPermissionPublic := common.GetMap(data, "permission_public"); nestedPermissionPublic != nil { + permissionPublic = nestedPermissionPublic + } + return map[string]interface{}{ + "permission_public": permissionPublic, + } +} + +// DriveFolderPermissionGet queries the permission_public settings for a Drive +// folder itself. +var DriveFolderPermissionGet = common.Shortcut{ + Service: "drive", + Command: "+folder-permission-get", + Description: "Get a Drive folder's sharing, copy, download, and comment permission settings", + Risk: "read", + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "url", Desc: "Drive folder URL, for example https://example.feishu.cn/drive/folder/"}, + {Name: "folder-token", Desc: "Drive folder token; mutually exclusive with --url"}, + }, + Tips: []string{ + "Pass exactly one of --url or --folder-token.", + "This shortcut reads the folder's own permission settings; it does not list child document permissions.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveFolderPermissionGetSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveFolderPermissionGetSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("Get Drive folder permission settings"). + GET(spec.apiPath()). + Params(spec.params()) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveFolderPermissionGetSpec(runtime) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for folder %s...\n", common.MaskToken(spec.FolderToken)) + data, err := runtime.CallAPITyped( + "GET", + spec.apiPath(), + spec.params(), + nil, + ) + if err != nil { + return err + } + + out := spec.output(runtime, data) + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "Type: folder\n") + fmt.Fprintf(w, "FolderToken: %s\n", spec.FolderToken) + fmt.Fprintf(w, "URL: %s\n", spec.url(runtime)) + }) + return nil + }, +} diff --git a/shortcuts/drive/drive_folder_permission_get_test.go b/shortcuts/drive/drive_folder_permission_get_test.go new file mode 100644 index 000000000..6d35ffcc7 --- /dev/null +++ b/shortcuts/drive/drive_folder_permission_get_test.go @@ -0,0 +1,221 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "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 newDriveFolderPermissionGetRuntime(t *testing.T, rawURL, folderToken string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "drive +folder-permission-get"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("folder-token", "", "") + if rawURL != "" { + if err := cmd.Flags().Set("url", rawURL); err != nil { + t.Fatalf("set --url: %v", err) + } + } + if folderToken != "" { + if err := cmd.Flags().Set("folder-token", folderToken); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + } + return common.TestNewRuntimeContext(cmd, driveTestConfig()) +} + +func TestDriveFolderPermissionGetSpecResolvesFolderURL(t *testing.T) { + t.Parallel() + + runtime := newDriveFolderPermissionGetRuntime(t, "https://example.feishu.cn/drive/folder/fldTok?from=share", "") + spec, err := readDriveFolderPermissionGetSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if spec.FolderToken != "fldTok" { + t.Fatalf("FolderToken = %q, want fldTok", spec.FolderToken) + } +} + +func TestDriveFolderPermissionGetSpecResolvesBareFolderToken(t *testing.T) { + t.Parallel() + + runtime := newDriveFolderPermissionGetRuntime(t, "", " fldTok ") + spec, err := readDriveFolderPermissionGetSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if spec.FolderToken != "fldTok" { + t.Fatalf("FolderToken = %q, want fldTok", spec.FolderToken) + } +} + +func TestDriveFolderPermissionGetSpecValidationErrorsAreTyped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURL string + folderToken string + wantParam string + wantMessage string + }{ + { + name: "missing locator", + wantParam: "--url", + wantMessage: "pass exactly one", + }, + { + name: "mutually exclusive locators", + rawURL: "https://example.feishu.cn/drive/folder/fldTok", + folderToken: "fldTok", + wantParam: "--url", + wantMessage: "mutually exclusive", + }, + { + name: "non-folder URL", + rawURL: "https://example.feishu.cn/docx/doxTok", + wantParam: "--url", + wantMessage: "must point to a Drive folder", + }, + { + name: "unsupported URL", + rawURL: "https://example.feishu.cn/calendar/calTok", + wantParam: "--url", + wantMessage: "unsupported --url", + }, + { + name: "invalid bare folder token", + folderToken: "../bad", + wantParam: "--folder-token", + wantMessage: "--folder-token", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveFolderPermissionGetRuntime(t, tt.rawURL, tt.folderToken) + _, err := readDriveFolderPermissionGetSpec(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 TestDriveFolderPermissionGetDryRunIncludesGETRequest(t *testing.T) { + t.Parallel() + + runtime := newDriveFolderPermissionGetRuntime(t, "https://example.feishu.cn/drive/folder/fldTok", "") + dry := DriveFolderPermissionGet.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{ + `"/open-apis/drive/v2/permissions/fldTok/public"`, + `"GET"`, + `"type":"folder"`, + } { + 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 TestDriveFolderPermissionGetExecutePreservesPermissionPublic(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/permissions/fldTok/public?type=folder", + 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_folder_field": "preserved", + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveFolderPermissionGet, []string{ + "+folder-permission-get", + "--folder-token", "fldTok", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + for _, key := range []string{"type", "folder_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_folder_field": "preserved", + } { + if permissionPublic[key] != want { + t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want) + } + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 281f8b9d8..3a2450234 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -31,6 +31,7 @@ func Shortcuts() []common.Shortcut { DriveTaskResult, DriveApplyPermission, DriveMemberAdd, + DriveFolderPermissionGet, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 5c12f3058..0ad95bba7 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -37,6 +37,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+task_result", "+apply-permission", "+member-add", + "+folder-permission-get", "+secure-label-list", "+secure-label-update", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index a1f1b9089..96786eb16 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 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/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 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询文件夹权限设置、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/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"] @@ -22,6 +22,7 @@ metadata: - 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":""}' --data '{"folder_token":"","name":"","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。 - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 +- 用户要**查询 Drive 文件夹自身的公开访问、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +folder-permission-get`;它只读取文件夹自身设置,不递归审计子文档权限。 - 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。 - 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。 - 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。 @@ -106,6 +107,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)。 +- 用户要查询 Drive 文件夹自身的公开访问和协作权限设置,使用 [`+folder-permission-get`](references/lark-drive-folder-permission-get.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。 @@ -148,6 +150,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 | | [`+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`。 | +| [`+folder-permission-get`](references/lark-drive-folder-permission-get.md) | 查询 Drive 文件夹自身的公开访问、协作者管理、安全与评论权限设置;支持 `--url` 或 `--folder-token`;不递归读取子文档权限。 | | [`+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-folder-permission-get.md b/skills/lark-drive/references/lark-drive-folder-permission-get.md new file mode 100644 index 000000000..358252b35 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-folder-permission-get.md @@ -0,0 +1,80 @@ +# drive +folder-permission-get(查询文件夹权限设置) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、身份选择、全局参数和权限错误处理。 + +本 skill 对应 shortcut:`lark-cli drive +folder-permission-get`。它直接读取 Drive 文件夹自身的公开访问和协作权限设置。 +## 适用场景 + +- 用户明确要查看“文件夹权限设置”“文件夹分享设置”“文件夹公开访问 / 协作者管理 / 安全 / 评论权限”。 +- 输入是 `/drive/folder/` URL,或已经拿到裸 `folder_token`。 +- 只需要读取当前文件夹自身设置,不需要递归扫描子文件、子文件夹或文档权限。 + +如果用户要做文件夹下所有文档的权限风险报告、批量整改、owner 转移或密级标签治理,进入 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md)。 + +## 命令 + +```bash +# 通过文件夹 URL 查询 +lark-cli drive +folder-permission-get \ + --url "https://example.feishu.cn/drive/folder/fldcnxxxxxxxxx" \ + --as user --format json + +# 通过裸 folder token 查询 +lark-cli drive +folder-permission-get \ + --folder-token "fldcnxxxxxxxxx" \ + --as bot --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 二选一 | Drive 文件夹 URL,必须是 `/drive/folder/` 路径。非 folder URL 会被拒绝。 | +| `--folder-token` | 二选一 | 裸 folder token。适合已经从 `drive +inspect`、`drive files list` 或其他流程中拿到 token 的场景。 | + +`--url` 与 `--folder-token` 必须且只能传一个。不要把文档、Wiki、Sheet、Base 或普通文件 URL 传给本 shortcut。 + +身份与输出格式沿用全局参数约定:按需使用 `--as user|bot`;自动化解析时使用 `--format json`。权限取决于当前身份是否能访问该文件夹,以及应用 / 用户授权是否满足 API 要求。 + +## 输出 + +成功时 `data` 只返回 `permission_public`,并完整透传服务端当前返回的公共访问和协作权限设置: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "permission_public": { + "comment_entity": "anyone_can_edit", + "external_access_entity": "open", + "link_share_entity": "anyone_readable", + "lock_switch": false, + "manage_collaborator_entity": "collaborator_can_edit", + "security_entity": "only_full_access", + "share_entity": "same_tenant" + } + } +} +``` + +`permission_public` 是服务端当前返回的完整权限设置对象。字段可能随 OpenAPI 演进增加或缺失;只根据实际返回字段做判断,不要臆造未返回的权限状态。JSON `data` 不包含 `type`、`folder_token` 或 `url`;如需定位目标,复用调用命令中的 `--url` / `--folder-token` 输入。 + +`--dry-run` 输出只展示待请求的 API、method 和 params,不额外输出顶层 `folder_token`。 + +## 边界 + +- 只读操作,不修改权限,不需要 `--yes`。 +- 只查询文件夹自身设置,不递归读取子文件夹或子文档权限。 +- 不返回协作者列表、继承链、历史权限变更、访问记录、DLP 或 AI 索引状态。 +- 本 shortcut 是 folder-only;其他文档类型继续使用 `drive permission.public get` 或权限治理 workflow。 +- 当前 raw command schema 未把 `folder` 纳入 `drive permission.public get --type`,不要用 raw command 猜 `type=folder`;文件夹读取走本 shortcut 的 v2 endpoint。 + +## 常见错误 + +| 症状 | 原因 | 处理 | +|------|------|------| +| `--url and --folder-token are mutually exclusive` | 同时传了两种输入 | 只保留一个输入。 | +| `--url or --folder-token is required` | 没传目标文件夹 | 传 `/drive/folder/` URL 或裸 `folder_token`。 | +| `--url must be a Drive folder URL` | URL 不是 `/drive/folder/` | 先确认资源类型;文档 / Wiki / Sheet 不走本 shortcut。 | +| Permission denied / missing scope | 当前身份无文件夹访问权或缺授权 | 按 [`lark-shared`](../../lark-shared/SKILL.md) 处理。bot 不能访问用户私有文件夹时,改用 `--as user` 或先授权 bot。 | 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 b9caec04a..72696ef1c 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 @@ -69,6 +69,14 @@ lark-cli drive permission.public get \ --as user --format json ``` +读取 Drive 文件夹自身 public permission: + +```bash +lark-cli drive +folder-permission-get \ + --folder-token "" \ + --as user --format json +``` + 按需读取访问统计: ```bash 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 82fd11760..415d1d8d1 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md @@ -119,9 +119,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.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。Drive 文件夹自身权限查询走 `drive +folder-permission-get`。 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` 只作为递归容器时,不执行 raw `permission.public get` / `patch`;如用户明确要查询文件夹自身公开访问和协作权限设置,可对该文件夹单独执行 `drive +folder-permission-get`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。 6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。 Wiki space / node 发现: @@ -133,7 +133,7 @@ Wiki space / node 发现: Drive folder 发现: -1. `/drive/folder/` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。 +1. `/drive/folder/` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +folder-permission-get` 读取该文件夹自身设置。 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` 中记录;继续处理其他可枚举分支。 @@ -141,7 +141,7 @@ 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.public get` 没有批量读取接口;对支持目标逐个读取。文件夹自身权限读取使用 `drive +folder-permission-get`。单个目标失败时记录 `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 和输出聚合。 @@ -170,7 +170,7 @@ Drive folder 发现: - 文档公共访问和协作权限设置修改(`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.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。文件夹自身设置用 `drive +folder-permission-get`。 - 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。 - 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。 - `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。 diff --git a/tests/cli_e2e/drive/drive_folder_permission_get_dryrun_test.go b/tests/cli_e2e/drive/drive_folder_permission_get_dryrun_test.go new file mode 100644 index 000000000..27ab149de --- /dev/null +++ b/tests/cli_e2e/drive/drive_folder_permission_get_dryrun_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_FolderPermissionGetDryRun(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 + }{ + { + name: "folder token", + args: []string{ + "drive", "+folder-permission-get", + "--folder-token", "fldE2E001", + "--dry-run", + }, + }, + { + name: "folder URL", + args: []string{ + "drive", "+folder-permission-get", + "--url", "https://example.feishu.cn/drive/folder/fldE2E001?from=share", + "--dry-run", + }, + }, + } + + 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, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v2/permissions/fldE2E001/public" { + t.Fatalf("url = %q, want v2 folder permission public endpoint\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.type").String(); got != "folder" { + t.Fatalf("params.type = %q, want folder\nstdout:\n%s", got, out) + } + if gjson.Get(out, "folder_token").Exists() { + t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out) + } + }) + } +}