From d29d3d74e36c49f6990953224a4ca7bc7868d311 Mon Sep 17 00:00:00 2001 From: uvwt Date: Wed, 14 Oct 2026 00:16:36 +0800 Subject: [PATCH 1/6] fix(resource-library): back up installed package files without redundant allowlist --- docs/cloud-resource-library-device.md | 4 +- internal/resourcelibrary/export.go | 67 +++++++---- internal/resourcelibrary/export_test.go | 114 +++++++++++++++++- internal/resourcelibrary/policy.go | 153 +++++------------------- 4 files changed, 186 insertions(+), 152 deletions(-) diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index 7a9e287ae..6a9bd6155 100644 --- a/docs/cloud-resource-library-device.md +++ b/docs/cloud-resource-library-device.md @@ -6,7 +6,7 @@ - Skill 仍是纯 `SKILL.md` 资源包,沿用现有本地/URL安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 - Plugin 仍是支持 Portable/OpenAI/Claude 的包,保留 `plugin_manage validate` / `install` / `update` 和内容绑定的 `review_token`;压缩包 64 MiB、提取后 256 MiB。Cloud 修订号不覆盖插件 manifest 原版号;同名非-local 版本不同内容仍拒绝。 -- **只允许导出重新安装所需的可移植源码快照**,排除所有 Env 真实值、MCP 环境、skill-data、私钥/凭证、session、Cookie、active tasks、缓存、设备绝对路径;即便允许文件有硬编码凭证风险,也应预览内容清单与风险提示。Plugin 附属的 Skill 不独立导出/计额,需导出完整 Plugin。 +- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。拒绝 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。对于被忽略的依赖给出警告,恢复时可能需要重新安装依赖或配置环境。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 - 本地检验需要负责 ZIP 路径穿越、符号链接、文件类型、文件数量、解包大小、插件命令/MCP 配置风险;Cloud 只能展示报告和摘要,不能代替设备安装器。 - 远程操作必须通过用户在 Cloud 明确确认的、绑定 node_id/操作/包摘要/短有效期的一次性挑战。**现有 `review_token` 是确定性内容标识,不是设备授权或用户确认**;安装前必须复用现有 `InstallReviewedSource` / `UpdateReviewedSource`,验证失败禁止安装。 - 不把大文件塞进 WebSocket `runtime.request`,只用其传控制与结果;压缩包走限额、鉴权的专用流式传输,支持离线失败、不后台补装;兼容未声明相应能力的旧 AgentDock,旧客户端 Runtime 仍只读。 @@ -19,7 +19,7 @@ 已落地的是设备本地控制面,不是 Cloud 备份或远程安装发布: - `POST /internal/runtime/resource-library` 只接收不超过 64 KiB 的控制 JSON。未实现 `ResourceLibraryRuntime` 的旧 Runtime 返回 `RESOURCE_LIBRARY_UNSUPPORTED`。 -- `export_prepare` 只导出已安装的受管 Skill/Plugin,使用递归可移植白名单,拒绝符号链接、特殊文件和私人运行时文件名,并给出硬编码凭证风险提示。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 +- `export_prepare` 只导出已安装的受管 Skill/Plugin,遵循原生安装器的包结构并拒绝符号链接、特殊文件和私有运行时数据;普通源码目录、文件名、扩展名不受额外限制,缓存忽略会返回告警;内容摘要按实际 ZIP 文件集计算,供后续原生安装器校验。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 - `export_upload` 只接受 `{action,upload_url,download_grant}`。它复用该 grant,再次核对 grant 未过期、ZIP 大小和归档摘要未变,然后用已配对 Device Token 以 `PUT`、`Content-Type: application/zip`、`Content-Length` 为实际大小,把文件流式送到 `https://{paired-endpoint}/v1/nodes/library/transfer/{ticket}`。请求结束(成功、失败或超时)后销毁 grant 和 ZIP,同一 grant 不能第二次上传。响应只返回 `size`、`archive_digest` 和 `content_digest`。 - `install_prepare` 只用已配对 Nexus 的 Device Token,通过现有出站 HTTPS 客户端下载候选 ZIP。其他 Origin、本机和私网地址拒绝。`package_digest` 是可选的预期内容摘要:Cloud 不能重算 Skill/Plugin 原生 digest。原生 validate 成功后,设备把实际 `package_digest` 绑定到一次性挑战;调用方提供了就必须匹配。审核报告和 Plugin `review_token` 仍由原生安装器产生。 - `install_commit` 仍必须精确匹配确认时的 node、操作、归档摘要、实际 `package_digest`、Plugin `review_token` 和一次性挑战,然后再调用现有 Skill 安装或 Plugin `InstallReviewedSource` / `UpdateReviewedSource`。字段不匹配不消耗挑战。 diff --git a/internal/resourcelibrary/export.go b/internal/resourcelibrary/export.go index 793d40c38..63a57b980 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -3,16 +3,20 @@ package resourcelibrary import ( "archive/zip" "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" "io" "os" "path/filepath" "regexp" + "sort" "strings" skills "github.com/uvwt/agentdock/internal/skill" ) -// Export 是一份只含可移植源码快照的 ZIP 摘要。本地绝对路径不会出现在结果里。 +// Export 是受管资源的安全备份 ZIP 摘要。本地绝对路径不会出现在结果里。 type Export struct { Files []FileEntry Warnings []string @@ -41,7 +45,7 @@ var devicePathMarkers = []string{ } // ExportDirectory 把一个已安装的受管目录打成 ZIP。 -// 白名单按递归路径生效:不在可移植快照里的文件会让整个导出失败,而不是静默丢弃。 +// 安装器已经接纳的源码不再经第二套扩展名或目录白名单筛选,只排除缓存并拒绝私有数据。 func ExportDirectory(root, kind, destination string) (Export, error) { if kind != KindSkill && kind != KindPlugin { return Export{}, failed("PACKAGE_KIND_INVALID", "validation", "package kind must be skill or plugin") @@ -65,11 +69,8 @@ func ExportDirectory(root, kind, destination string) (Export, error) { return Export{}, failed("PACKAGE_INVALID", "validation", "managed Plugin export requires plugin.json") } - contentDigest, err := skills.DigestPackageContent(root) - if err != nil { - return Export{}, failed("PACKAGE_INVALID", "validation", "managed package content digest failed") - } - size, archiveDigest, err := writePortableZip(root, files, destination, archiveLimit(kind)) + // 内容摘要必须对应实际备份的文件;原目录可能包含被忽略的缓存与来源元数据。 + size, archiveDigest, contentDigest, err := writePortableZip(root, files, destination, archiveLimit(kind)) if err != nil { return Export{}, err } @@ -116,6 +117,14 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { } return nil } + if ignoredExportPath(relative) { + // 不默默丢掉可能需要重新安装的依赖,向 Cloud 明确报告被排除的路径。 + warnings = append(warnings, relative+" was excluded from backup (local metadata or generated dependencies)") + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } // os.Root 逐段拒绝符号链接替换。WalkDir 看到的类型不够,因为中间目录可能在遍历后被换掉。 info, err := fsRoot.Lstat(relative) if err != nil { @@ -124,13 +133,13 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if info.Mode()&os.ModeSymlink != 0 { return failedf("PACKAGE_INVALID", "validation", "symlink is not allowed: %s", relative) } + if err := validateArchiveName(relative); err != nil { + return err + } if reason := rejectedPrivatePath(relative); reason != "" { return failedf("PACKAGE_PRIVATE_DATA", "validation", "export refused %s: %s", reason, relative) } if info.IsDir() { - if !allowedDirectory(kind, relative) { - return failedf("PACKAGE_NOT_PORTABLE", "validation", "path is outside the portable allowlist: %s", relative) - } return nil } if !info.Mode().IsRegular() { @@ -140,9 +149,6 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if kind == KindSkill && relative != "SKILL.md" && strings.HasSuffix(relative, "/SKILL.md") { return failedf("PACKAGE_INVALID", "validation", "managed Skill export contains a nested SKILL.md: %s", relative) } - if !allowedFile(kind, relative) { - return failedf("PACKAGE_NOT_PORTABLE", "validation", "path is outside the portable allowlist: %s", relative) - } if info.Size() < 0 || total > extractLimit(kind)-info.Size() { return failedf("PACKAGE_INVALID", "validation", "package exceeds %d bytes", extractLimit(kind)) } @@ -164,6 +170,7 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if len(files) == 0 { return nil, nil, failed("PACKAGE_INVALID", "validation", "managed package has no portable files") } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) return files, warnings, nil } @@ -212,28 +219,35 @@ func scanHardcodedSecrets(root *os.Root, relative string) ([]string, error) { return warnings, nil } -func writePortableZip(root string, files []FileEntry, destination string, limit int64) (int64, string, error) { +func writePortableZip(root string, files []FileEntry, destination string, limit int64) (int64, string, string, error) { fsRoot, err := os.OpenRoot(root) if err != nil { - return 0, "", failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") + return 0, "", "", failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") } defer fsRoot.Close() output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { - return 0, "", failed("PACKAGE_INVALID", "validation", "export archive cannot be created") + return 0, "", "", failed("PACKAGE_INVALID", "validation", "export archive cannot be created") } writer := zip.NewWriter(output) failedWrite := false + // 使用安装器相同的路径、权限和内容序列计算 digest,避免缓存过滤后摘要仍指向原目录。 + contentHash := sha256.New() for _, file := range files { info, err := fsRoot.Lstat(file.Path) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != file.Size { failedWrite = true break } header := &zip.FileHeader{Name: file.Path, Method: zip.Deflate} - // 0600 与安装器的内容摘要权限掩码一致,避免导出后再安装产生虚假 digest 差异。 - header.SetMode(0o600) + // 与原生 ZIP 安装器的权限归一化一致,保留脚本可执行位。 + mode := info.Mode().Perm() & 0o755 + if mode == 0 { + mode = 0o600 + } + header.SetMode(mode) + fmt.Fprintf(contentHash, "%s\x00%o\x00%d\x00", file.Path, mode, file.Size) entry, err := writer.CreateHeader(header) if err != nil { failedWrite = true @@ -244,32 +258,33 @@ func writePortableZip(root string, files []FileEntry, destination string, limit failedWrite = true break } - _, copyErr := io.Copy(entry, io.LimitReader(source, extractLimit(KindPlugin)+1)) + written, copyErr := io.Copy(io.MultiWriter(entry, contentHash), io.LimitReader(source, file.Size+1)) closeErr := source.Close() - if copyErr != nil || closeErr != nil { + if copyErr != nil || closeErr != nil || written != file.Size { failedWrite = true break } + _, _ = contentHash.Write([]byte{0}) } closeZipErr := writer.Close() closeFileErr := output.Close() if failedWrite || closeZipErr != nil || closeFileErr != nil { _ = os.Remove(destination) - return 0, "", failed("PACKAGE_INVALID", "validation", "export archive cannot be written") + return 0, "", "", failed("PACKAGE_INVALID", "validation", "export archive cannot be written") } info, err := os.Lstat(destination) if err != nil { _ = os.Remove(destination) - return 0, "", failed("PACKAGE_INVALID", "validation", "export archive cannot be inspected") + return 0, "", "", failed("PACKAGE_INVALID", "validation", "export archive cannot be inspected") } if info.Size() > limit || !info.Mode().IsRegular() { _ = os.Remove(destination) - return 0, "", failedf("PACKAGE_INVALID", "validation", "export archive exceeds %d bytes", limit) + return 0, "", "", failedf("PACKAGE_INVALID", "validation", "export archive exceeds %d bytes", limit) } digest, err := skills.DigestFile(destination) if err != nil { _ = os.Remove(destination) - return 0, "", failed("PACKAGE_INVALID", "validation", "export archive digest failed") + return 0, "", "", failed("PACKAGE_INVALID", "validation", "export archive digest failed") } - return info.Size(), digest, nil + return info.Size(), digest, "sha256:" + hex.EncodeToString(contentHash.Sum(nil)), nil } diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 58d78de59..0b0306b53 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -2,8 +2,10 @@ package resourcelibrary import ( "archive/zip" + "context" "os" "path/filepath" + "strings" "testing" ) @@ -40,7 +42,7 @@ func TestExportDirectory_拒绝私人文件并保留可移植清单(t *testing.T } } -func TestExportDirectory_拒绝符号链接特殊文件和白名单外路径(t *testing.T) { +func TestExportDirectory_拒绝符号链接但允许自定义目录(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n\n# Demo\n") if err := os.Symlink(filepath.Join(root, "SKILL.md"), filepath.Join(root, "references")); err != nil { @@ -52,8 +54,89 @@ func TestExportDirectory_拒绝符号链接特殊文件和白名单外路径(t * os.Remove(filepath.Join(root, "references")) writeFile(t, filepath.Join(root, "bin", "tool"), "#!/bin/sh\n") - if _, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "bin.zip")); err == nil { - t.Fatal("export accepted a path outside the allowlist") + writeFile(t, filepath.Join(root, "test_run.py"), "print('ok')\n") + writeFile(t, filepath.Join(root, "package.json"), "{}\n") + writeFile(t, filepath.Join(root, "docs", "使用说明.md"), "# 正常文档\n") + if err := os.Chmod(filepath.Join(root, "bin", "tool"), 0o755); err != nil { + t.Fatal(err) + } + archive := filepath.Join(t.TempDir(), "skill.zip") + exported, err := ExportDirectory(root, KindSkill, archive) + if err != nil { + t.Fatal(err) + } + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) + } + if !hasFile(exported.Files, "bin/tool") || !hasFile(exported.Files, "test_run.py") || + !hasFile(exported.Files, "package.json") || !hasFile(exported.Files, "docs/使用说明.md") { + t.Fatalf("expected installed package files in backup, got %#v", exported.Files) + } + zipReader, err := zip.OpenReader(archive) + if err != nil { + t.Fatal(err) + } + for _, file := range zipReader.File { + if file.Name == "bin/tool" && file.Mode().Perm()&0o111 == 0 { + t.Errorf("export lost executable bit: %s %v", file.Name, file.Mode()) + } + } + if err := zipReader.Close(); err != nil { + t.Fatal(err) + } + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("native validator rejected exported Skill: preview=%#v err=%v", preview, err) + } +} + +func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘要一致(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Backup.\n---\n\n# Demo\n\nBody.\n") + writeFile(t, filepath.Join(root, "run.py"), "print('hello')\n") + writeFile(t, filepath.Join(root, "_meta.json"), "{}\n") + for _, relative := range []string{ + ".clawhub/origin.json", ".git/config", "node_modules/lib/package.json", + "__pycache__/run.pyc", "scripts/__pycache__/cache.pyc", ".env.example", + } { + writeFile(t, filepath.Join(root, filepath.FromSlash(relative)), "generated\n") + } + + archive := filepath.Join(t.TempDir(), "skill.zip") + exported, err := ExportDirectory(root, KindSkill, archive) + if err != nil { + t.Fatal(err) + } + if len(exported.Files) != 3 || !hasFile(exported.Files, "_meta.json") || len(exported.Warnings) == 0 { + t.Fatalf("unexpected filtered files: %#v", exported.Files) + } + for _, name := range zipNames(t, archive) { + if strings.Contains(name, "__pycache__") || strings.Contains(name, "node_modules") || + strings.Contains(name, ".clawhub") || strings.Contains(name, ".git/") || name == ".env.example" { + t.Fatalf("backup included local-only data: %s", name) + } + } + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("snapshot digest differs from native installer: preview=%#v err=%v", preview, err) + } +} + +func TestExportDirectory_敏感文件仍然禁止但普通源码可备份(t *testing.T) { + for _, secret := range []string{ + ".env", ".env.production", "credentials/token.json", "secret.json", "private_key.yaml", + ".ssh/config", "state.sqlite", "auth-session.json", "token.json", "credentials.csv", + } { + t.Run(secret, func(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export.\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, "session.ts"), "export const start = true;\n") + writeFile(t, filepath.Join(root, filepath.FromSlash(secret)), "private content") + _, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "skill.zip")) + if !errorCode(err, "PACKAGE_PRIVATE_DATA") { + t.Fatalf("sensitive path %s was not rejected: %v", secret, err) + } + }) } } @@ -78,6 +161,31 @@ func TestExportDirectory_插件只导出可移植快照(t *testing.T) { } } +func TestExportDirectory_Plugin保留原生安装器支持的文件(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "plugin.json"), `{"name":"demo-plugin","version":"1.0.0","description":"Backup"}`) + writeFile(t, filepath.Join(root, "package.json"), `{"name":"demo-plugin"}`) + writeFile(t, filepath.Join(root, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: Backup.\n---\n\n# Demo\n\nBody.\n") + writeFile(t, filepath.Join(root, "skills", "demo-skill", "test_run.py"), "print('ok')\n") + writeFile(t, filepath.Join(root, "assets", "说明.md"), "# 文档\n") + archive := filepath.Join(t.TempDir(), "plugin.zip") + exported, err := ExportDirectory(root, KindPlugin, archive) + if err != nil { + t.Fatal(err) + } + if err := InspectArchive(archive, KindPlugin); err != nil { + t.Fatal(err) + } + if !hasFile(exported.Files, "package.json") || !hasFile(exported.Files, "skills/demo-skill/test_run.py") { + t.Fatalf("plugin files lost: %#v", exported.Files) + } + manager := newPluginManager(t, filepath.Join(t.TempDir(), "agentdock-home")) + review := manager.ValidateSource(context.Background(), archive) + if !review.Valid || review.PackageDigest != exported.ContentDigest { + t.Fatalf("native Plugin validator disagrees with backup digest: %#v, exported=%s", review, exported.ContentDigest) + } +} + func TestInspectArchive_拒绝路径穿越符号链接和秘密文件(t *testing.T) { traversal := filepath.Join(t.TempDir(), "traversal.zip") writeRawZip(t, traversal, map[string]string{"../evil.txt": "owned"}) diff --git a/internal/resourcelibrary/policy.go b/internal/resourcelibrary/policy.go index ea99fad78..b5201461a 100644 --- a/internal/resourcelibrary/policy.go +++ b/internal/resourcelibrary/policy.go @@ -6,9 +6,6 @@ import ( "os" "path" "strings" - "unicode" - - "github.com/uvwt/agentdock/internal/skillspec" ) const ( @@ -55,143 +52,57 @@ func rejectedPrivatePath(relative string) string { switch extension { case ".pem", ".key", ".p12", ".pfx", ".keystore", ".jks": return "key material" + case ".sqlite", ".db", ".bak", ".session": + return "private runtime data" } if segment == ".env" || strings.HasPrefix(segment, ".env.") || extension == ".env" { return "environment file" } - switch stem { + // 以普通源码命名的 session.ts、key.go 不属于凭证文件。 + // 只拒绝明确的私有目录/文件名,以及常见配置格式中的凭证文件。 + switch segment { case "cookie", "cookies", "session", "sessions", "secret", "secrets", "credential", "credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", - "key", "privatekey", "private_key": + "key", "privatekey", "private_key", "skill-data", ".skill-data", + ".agentdock", ".ssh", ".aws", ".kube": return "private runtime data" } - if strings.HasSuffix(stem, "-key") || strings.HasSuffix(stem, "_key") || - strings.HasSuffix(stem, "-secret") || strings.HasSuffix(stem, "_secret") || - strings.HasSuffix(stem, "-cookie") || strings.HasSuffix(stem, "_cookie") || - strings.HasSuffix(stem, "-session") || strings.HasSuffix(stem, "_session") { - return "private runtime data" - } - switch segment { - case "skill-data", ".skill-data", "__pycache__", "node_modules": - return "private runtime data" + switch extension { + case ".json", ".yaml", ".yml", ".toml", ".ini", ".txt", ".csv", ".xml", ".conf", ".cfg", ".properties": + switch stem { + case "cookie", "cookies", "session", "sessions", "secret", "secrets", + "credential", "credentials", "privatekey", "private_key", "key", + "token", "tokens", "access_token", "api_key", "password", "passwords": + return "private runtime data" + } + if strings.HasSuffix(stem, "-key") || strings.HasSuffix(stem, "_key") || + strings.HasSuffix(stem, "-secret") || strings.HasSuffix(stem, "_secret") || + strings.HasSuffix(stem, "-cookie") || strings.HasSuffix(stem, "_cookie") || + strings.HasSuffix(stem, "-session") || strings.HasSuffix(stem, "_session") { + return "private runtime data" + } } } return "" } -func allowedSkillFile(relative string) bool { - switch relative { - case "SKILL.md", "run.py", "README.md", "LICENSE", "LICENSE.md", "NOTICE", "NOTICE.md": - return true - } - directory, ok := allowedSkillDirectory(path.Dir(relative)) - if !ok { - return false - } - base := path.Base(relative) - if !safePathSegment(base) { - return false - } - extension := strings.ToLower(path.Ext(base)) - if extension == "" { - // 无扩展名只允许出现在 scripts 下,避免把任意二进制放进可移植快照。 - return directory == "scripts" - } - return allowedSourceExtension(extension) -} - -func allowedSkillDirectory(directory string) (string, bool) { - if directory == "." { - return "", false - } - parts := strings.Split(directory, "/") - switch parts[0] { - case "references", "scripts", "tests", "assets", "templates", "docs": - default: - return "", false - } - for _, part := range parts { - if !safePathSegment(part) { - return "", false - } - } - return parts[0], true -} - -func allowedPluginFile(relative string) bool { - switch relative { - case "plugin.json", "mcp.json", "README.md", "LICENSE", "LICENSE.md", "NOTICE", "NOTICE.md": - return true - } - parts := strings.Split(relative, "/") - if len(parts) < 3 || parts[0] != "skills" { - return false - } - if err := skillspec.ValidateName(parts[1]); err != nil { - return false - } - if !safePathSegment(parts[1]) { - return false - } - inner := strings.Join(parts[2:], "/") - return allowedSkillFile(inner) -} - -func allowedDirectory(kind, relative string) bool { - relative = path.Clean(relative) - if kind == KindPlugin { - parts := strings.Split(relative, "/") - if parts[0] != "skills" || !safePathSegment(parts[0]) { - return false - } - if len(parts) == 1 { - return true - } - if err := skillspec.ValidateName(parts[1]); err != nil || !safePathSegment(parts[1]) { - return false - } - if len(parts) == 2 { +// ignoredExportPath 是与源码分离的缓存、安装来源元数据和环境示例文件。 +// 它们不属于可恢复的包内容,不应因本机产生的缓存而阻断整份备份。 +// 不忽略用户脚本、任意目录或普通点文件;敏感数据由 rejectedPrivatePath 明确拒绝。 +func ignoredExportPath(relative string) bool { + for _, segment := range strings.Split(strings.ToLower(relative), "/") { + switch segment { + case ".git", ".hg", ".svn", ".clawhub", "__pycache__", "node_modules", + ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".venv", "venv", + ".env.example", ".env.sample", ".env.template": return true } - _, ok := allowedSkillDirectory(strings.Join(parts[2:], "/")) - return ok - } - _, ok := allowedSkillDirectory(relative) - return ok -} - -func allowedFile(kind, relative string) bool { - if kind == KindPlugin { - return allowedPluginFile(relative) - } - return allowedSkillFile(relative) -} - -func safePathSegment(segment string) bool { - if segment == "" || segment == "." || segment == ".." || strings.HasPrefix(segment, ".") { - return false - } - for _, char := range segment { - if char > unicode.MaxASCII || char < 0x20 || char == '\\' || char == ':' { - return false - } - } - return true -} - -func allowedSourceExtension(extension string) bool { - switch extension { - case ".md", ".txt", ".py", ".sh", ".bash", ".json", ".yml", ".yaml", ".toml", - ".js", ".ts", ".html", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", - ".csv", ".xml", ".tpl", ".jinja", ".go", ".rs", ".rb", ".php", ".ps1": - return true - default: - return false } + return false } // InspectArchive 在把候选 ZIP 交给原生安装器之前拒绝明显危险的包。 -// 扩展名白名单只用于导出;入站包仍由 Skill/Plugin 安装器决定能不能安装, +// 不对入站包施加源码扩展名白名单;Skill/Plugin 能否安装仍由原生安装器决定, // 这里负责路径穿越、符号链接、特殊文件、私钥文件名、文件数和解压大小。 func InspectArchive(archivePath, kind string) error { reader, err := zip.OpenReader(archivePath) From 9f84628dce779e589594eb0b86817b05395f8b54 Mon Sep 17 00:00:00 2001 From: uvwt Date: Wed, 14 Oct 2026 00:25:27 +0800 Subject: [PATCH 2/6] fix(resource-library): omit local private files from cloud backups --- docs/cloud-resource-library-device.md | 4 +- internal/resourcelibrary/export.go | 8 ++- internal/resourcelibrary/export_test.go | 94 +++++++++++++++++++------ 3 files changed, 81 insertions(+), 25 deletions(-) diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index 6a9bd6155..8705c5f53 100644 --- a/docs/cloud-resource-library-device.md +++ b/docs/cloud-resource-library-device.md @@ -6,7 +6,7 @@ - Skill 仍是纯 `SKILL.md` 资源包,沿用现有本地/URL安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 - Plugin 仍是支持 Portable/OpenAI/Claude 的包,保留 `plugin_manage validate` / `install` / `update` 和内容绑定的 `review_token`;压缩包 64 MiB、提取后 256 MiB。Cloud 修订号不覆盖插件 manifest 原版号;同名非-local 版本不同内容仍拒绝。 -- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。拒绝 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。对于被忽略的依赖给出警告,恢复时可能需要重新安装依赖或配置环境。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 +- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。设备打包时自动排除 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据,不修改用户本地文件;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。通过文件名/路径判断的排除项会明确产生告警,恢复时可能需要重新安装依赖或配置环境;Cloud 和设备入站仍拒绝带有这些私密文件的恶意 ZIP。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 - 本地检验需要负责 ZIP 路径穿越、符号链接、文件类型、文件数量、解包大小、插件命令/MCP 配置风险;Cloud 只能展示报告和摘要,不能代替设备安装器。 - 远程操作必须通过用户在 Cloud 明确确认的、绑定 node_id/操作/包摘要/短有效期的一次性挑战。**现有 `review_token` 是确定性内容标识,不是设备授权或用户确认**;安装前必须复用现有 `InstallReviewedSource` / `UpdateReviewedSource`,验证失败禁止安装。 - 不把大文件塞进 WebSocket `runtime.request`,只用其传控制与结果;压缩包走限额、鉴权的专用流式传输,支持离线失败、不后台补装;兼容未声明相应能力的旧 AgentDock,旧客户端 Runtime 仍只读。 @@ -19,7 +19,7 @@ 已落地的是设备本地控制面,不是 Cloud 备份或远程安装发布: - `POST /internal/runtime/resource-library` 只接收不超过 64 KiB 的控制 JSON。未实现 `ResourceLibraryRuntime` 的旧 Runtime 返回 `RESOURCE_LIBRARY_UNSUPPORTED`。 -- `export_prepare` 只导出已安装的受管 Skill/Plugin,遵循原生安装器的包结构并拒绝符号链接、特殊文件和私有运行时数据;普通源码目录、文件名、扩展名不受额外限制,缓存忽略会返回告警;内容摘要按实际 ZIP 文件集计算,供后续原生安装器校验。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 +- `export_prepare` 只导出已安装的受管 Skill/Plugin,遵循原生安装器的包结构并拒绝符号链接、特殊文件;私有运行时文件与缓存在打包前自动排除并返回告警;普通源码目录、文件名、扩展名不受额外限制;内容摘要按实际 ZIP 文件集计算,供后续原生安装器校验。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 - `export_upload` 只接受 `{action,upload_url,download_grant}`。它复用该 grant,再次核对 grant 未过期、ZIP 大小和归档摘要未变,然后用已配对 Device Token 以 `PUT`、`Content-Type: application/zip`、`Content-Length` 为实际大小,把文件流式送到 `https://{paired-endpoint}/v1/nodes/library/transfer/{ticket}`。请求结束(成功、失败或超时)后销毁 grant 和 ZIP,同一 grant 不能第二次上传。响应只返回 `size`、`archive_digest` 和 `content_digest`。 - `install_prepare` 只用已配对 Nexus 的 Device Token,通过现有出站 HTTPS 客户端下载候选 ZIP。其他 Origin、本机和私网地址拒绝。`package_digest` 是可选的预期内容摘要:Cloud 不能重算 Skill/Plugin 原生 digest。原生 validate 成功后,设备把实际 `package_digest` 绑定到一次性挑战;调用方提供了就必须匹配。审核报告和 Plugin `review_token` 仍由原生安装器产生。 - `install_commit` 仍必须精确匹配确认时的 node、操作、归档摘要、实际 `package_digest`、Plugin `review_token` 和一次性挑战,然后再调用现有 Skill 安装或 Plugin `InstallReviewedSource` / `UpdateReviewedSource`。字段不匹配不消耗挑战。 diff --git a/internal/resourcelibrary/export.go b/internal/resourcelibrary/export.go index 63a57b980..b565b15fd 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -137,7 +137,13 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { return err } if reason := rejectedPrivatePath(relative); reason != "" { - return failedf("PACKAGE_PRIVATE_DATA", "validation", "export refused %s: %s", reason, relative) + // 私密文件不能离开用户设备,但也不应该阻断整个 Skill/Plugin 备份。 + // 跳过源文件,不改动本地安装;让用户知道恢复时需重新配置凭证。 + warnings = append(warnings, relative+" was excluded from backup ("+reason+"); reconfigure on restore") + if info.IsDir() { + return filepath.SkipDir + } + return nil } if info.IsDir() { return nil diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 0b0306b53..3ef8a2ff2 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -9,19 +9,15 @@ import ( "testing" ) -func TestExportDirectory_拒绝私人文件并保留可移植清单(t *testing.T) { +func TestExportDirectory_自动排除隐私文件而不修改本地Skill(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n\n# Demo\n\npassword: \"supersecretvalue\"\n") writeFile(t, filepath.Join(root, "references", "guide.md"), "Use a relative path.\n") writeFile(t, filepath.Join(root, ".env"), "TOKEN=real\n") + writeFile(t, filepath.Join(root, "credentials", "token.json"), `{"token":"real"}`) - destination := filepath.Join(t.TempDir(), "skill.zip") - if _, err := ExportDirectory(root, KindSkill, destination); err == nil { - t.Fatal("export accepted a Skill package that contains .env") - } - - os.Remove(filepath.Join(root, ".env")) - exported, err := ExportDirectory(root, KindSkill, destination) + archive := filepath.Join(t.TempDir(), "skill.zip") + exported, err := ExportDirectory(root, KindSkill, archive) if err != nil { t.Fatal(err) } @@ -31,15 +27,32 @@ func TestExportDirectory_拒绝私人文件并保留可移植清单(t *testing.T if !hasFile(exported.Files, "SKILL.md") || !hasFile(exported.Files, "references/guide.md") { t.Fatalf("files = %#v", exported.Files) } + if hasFile(exported.Files, ".env") || hasFile(exported.Files, "credentials/token.json") { + t.Fatalf("private data appeared in export manifest: %#v", exported.Files) + } if len(exported.Warnings) == 0 { - t.Fatal("hardcoded credential warning missing") + t.Fatal("export must explain that private data was omitted") + } + warnings := strings.Join(exported.Warnings, "\n") + if !strings.Contains(warnings, ".env") || !strings.Contains(warnings, "credentials") || + strings.Contains(warnings, "TOKEN=real") || strings.Contains(warnings, `"token":"real"`) { + t.Fatalf("warnings should name excluded paths, never expose values: %s", warnings) } - names := zipNames(t, destination) - for _, name := range names { - if name == ".env" || name == ".pem" { - t.Fatalf("exported zip contains private file %s", name) + for _, name := range zipNames(t, archive) { + if name == ".env" || strings.HasPrefix(name, "credentials/") { + t.Fatalf("exported ZIP contains private data: %s", name) } } + if actual, err := os.ReadFile(filepath.Join(root, ".env")); err != nil || string(actual) != "TOKEN=real\n" { + t.Fatalf("local .env was modified: %q %v", actual, err) + } + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatalf("exported ZIP must pass device safety inspection: %v", err) + } + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("exported ZIP cannot be restored: %#v %v", preview, err) + } } func TestExportDirectory_拒绝符号链接但允许自定义目录(t *testing.T) { @@ -122,19 +135,39 @@ func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘 } } -func TestExportDirectory_敏感文件仍然禁止但普通源码可备份(t *testing.T) { +func TestExportDirectory_不同私有路径自动排除且不影响普通源码(t *testing.T) { for _, secret := range []string{ ".env", ".env.production", "credentials/token.json", "secret.json", "private_key.yaml", ".ssh/config", "state.sqlite", "auth-session.json", "token.json", "credentials.csv", + "secrets/key.pem", "data/cookies.json", "data/.env", "data/skill-data/state.json", } { t.Run(secret, func(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export.\n---\n\n# Demo\n") writeFile(t, filepath.Join(root, "session.ts"), "export const start = true;\n") - writeFile(t, filepath.Join(root, filepath.FromSlash(secret)), "private content") - _, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "skill.zip")) - if !errorCode(err, "PACKAGE_PRIVATE_DATA") { - t.Fatalf("sensitive path %s was not rejected: %v", secret, err) + source := filepath.Join(root, filepath.FromSlash(secret)) + writeFile(t, source, "private content") + archive := filepath.Join(t.TempDir(), "skill.zip") + exported, err := ExportDirectory(root, KindSkill, archive) + if err != nil { + t.Fatalf("backup should skip private data: %v", err) + } + if hasFile(exported.Files, secret) || !hasFile(exported.Files, "session.ts") { + t.Fatalf("wrong export manifest: %#v", exported.Files) + } + for _, name := range zipNames(t, archive) { + if name == secret { + t.Fatalf("private data was uploaded: %s", name) + } + } + if len(exported.Warnings) == 0 { + t.Fatal("omitted private files need a visible warning") + } + if actual, err := os.ReadFile(source); err != nil || string(actual) != "private content" { + t.Fatalf("original file was modified: %q %v", actual, err) + } + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) } }) } @@ -151,13 +184,30 @@ func TestExportDirectory_Skill只允许根目录Manifest(t *testing.T) { } } -func TestExportDirectory_插件只导出可移植快照(t *testing.T) { +func TestExportDirectory_Plugin也自动排除隐私文件(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "plugin.json"), `{"name":"demo-plugin","version":"1.0.0","description":"Demo"}`) - writeFile(t, filepath.Join(root, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: Plugin skill.\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: Plugin skill.\n---\n\n# Demo\n\nBody.\n") writeFile(t, filepath.Join(root, "notes.pem"), "-----BEGIN PRIVATE KEY-----\n") - if _, err := ExportDirectory(root, KindPlugin, filepath.Join(t.TempDir(), "plugin.zip")); err == nil { - t.Fatal("export accepted a private key file") + writeFile(t, filepath.Join(root, "skills", "demo-skill", ".env"), "API_KEY=secret\n") + archive := filepath.Join(t.TempDir(), "plugin.zip") + exported, err := ExportDirectory(root, KindPlugin, archive) + if err != nil { + t.Fatal(err) + } + if hasFile(exported.Files, "notes.pem") || hasFile(exported.Files, "skills/demo-skill/.env") { + t.Fatalf("private Plugin files were included: %#v", exported.Files) + } + if len(exported.Warnings) < 2 { + t.Fatalf("expected omitted-file warnings, got: %#v", exported.Warnings) + } + if err := InspectArchive(archive, KindPlugin); err != nil { + t.Fatal(err) + } + manager := newPluginManager(t, filepath.Join(t.TempDir(), "agentdock-home")) + review := manager.ValidateSource(context.Background(), archive) + if !review.Valid || review.PackageDigest != exported.ContentDigest { + t.Fatalf("filtered Plugin must still pass native validation: %#v, exported=%s", review, exported.ContentDigest) } } From b98d421c7ebc36b2613686478d41aac49a10c1f0 Mon Sep 17 00:00:00 2001 From: uvwt Date: Wed, 14 Oct 2026 00:39:59 +0800 Subject: [PATCH 3/6] fix(resource-library): preserve nonsecret files in credential-named dirs --- internal/resourcelibrary/export_test.go | 27 +++++++++++++++++++++++++ internal/resourcelibrary/policy.go | 7 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 3ef8a2ff2..cf454ff89 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -135,6 +135,33 @@ func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘 } } +func TestExportDirectory_敏感命名目录仍可保存正常资源文件(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: demo\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, "credentials", "README.md"), "Document non-secret configuration.\n") + writeFile(t, filepath.Join(root, "secrets", "examples", "mock.json"), "{}\n") + writeFile(t, filepath.Join(root, "credentials", "token.json"), "DO_NOT_BACK_UP") + writeFile(t, filepath.Join(root, "secrets", "private.key"), "DO_NOT_BACK_UP") + archive := filepath.Join(t.TempDir(), "backup.zip") + exported, err := ExportDirectory(root, KindSkill, archive) + if err != nil { + t.Fatal(err) + } + if !hasFile(exported.Files, "credentials/README.md") || !hasFile(exported.Files, "secrets/examples/mock.json") { + t.Fatalf("non-secret files were omitted: %#v", exported.Files) + } + if hasFile(exported.Files, "credentials/token.json") || hasFile(exported.Files, "secrets/private.key") { + t.Fatalf("private files were included: %#v", exported.Files) + } + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) + } + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("filtered Skill cannot be restored: %#v %v", preview, err) + } +} + func TestExportDirectory_不同私有路径自动排除且不影响普通源码(t *testing.T) { for _, secret := range []string{ ".env", ".env.production", "credentials/token.json", "secret.json", "private_key.yaml", diff --git a/internal/resourcelibrary/policy.go b/internal/resourcelibrary/policy.go index b5201461a..47eb4c937 100644 --- a/internal/resourcelibrary/policy.go +++ b/internal/resourcelibrary/policy.go @@ -59,10 +59,11 @@ func rejectedPrivatePath(relative string) string { return "environment file" } // 以普通源码命名的 session.ts、key.go 不属于凭证文件。 - // 只拒绝明确的私有目录/文件名,以及常见配置格式中的凭证文件。 + // credentials/、secrets/ 可能包含正常资源文件,目录名不作为整目录拒绝的依据; + // 其中明确的密钥、Token、凭证文件名仍按下方规则过滤。 switch segment { - case "cookie", "cookies", "session", "sessions", "secret", "secrets", - "credential", "credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", + case "cookie", "cookies", "session", "sessions", "secret", + "credential", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "key", "privatekey", "private_key", "skill-data", ".skill-data", ".agentdock", ".ssh", ".aws", ".kube": return "private runtime data" From 7b675af5c8e111414d4663f093d5a09eb229c0c8 Mon Sep 17 00:00:00 2001 From: uvwt Date: Wed, 14 Oct 2026 00:42:20 +0800 Subject: [PATCH 4/6] fix(library): preserve non-secret environment templates in backups --- docs/cloud-resource-library-device.md | 2 +- internal/resourcelibrary/export_test.go | 7 ++++--- internal/resourcelibrary/policy.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index 8705c5f53..1bb7fb401 100644 --- a/docs/cloud-resource-library-device.md +++ b/docs/cloud-resource-library-device.md @@ -6,7 +6,7 @@ - Skill 仍是纯 `SKILL.md` 资源包,沿用现有本地/URL安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 - Plugin 仍是支持 Portable/OpenAI/Claude 的包,保留 `plugin_manage validate` / `install` / `update` 和内容绑定的 `review_token`;压缩包 64 MiB、提取后 256 MiB。Cloud 修订号不覆盖插件 manifest 原版号;同名非-local 版本不同内容仍拒绝。 -- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。设备打包时自动排除 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据,不修改用户本地文件;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。通过文件名/路径判断的排除项会明确产生告警,恢复时可能需要重新安装依赖或配置环境;Cloud 和设备入站仍拒绝带有这些私密文件的恶意 ZIP。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 +- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。设备打包时自动排除 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据,不修改用户本地文件;保留 `.env.example` 等无真实凭据的配置模板;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。通过文件名/路径判断的排除项会明确产生告警,恢复时可能需要重新安装依赖或配置环境;Cloud 和设备入站仍拒绝带有这些私密文件的恶意 ZIP。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 - 本地检验需要负责 ZIP 路径穿越、符号链接、文件类型、文件数量、解包大小、插件命令/MCP 配置风险;Cloud 只能展示报告和摘要,不能代替设备安装器。 - 远程操作必须通过用户在 Cloud 明确确认的、绑定 node_id/操作/包摘要/短有效期的一次性挑战。**现有 `review_token` 是确定性内容标识,不是设备授权或用户确认**;安装前必须复用现有 `InstallReviewedSource` / `UpdateReviewedSource`,验证失败禁止安装。 - 不把大文件塞进 WebSocket `runtime.request`,只用其传控制与结果;压缩包走限额、鉴权的专用流式传输,支持离线失败、不后台补装;兼容未声明相应能力的旧 AgentDock,旧客户端 Runtime 仍只读。 diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index cf454ff89..8980326d4 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -108,9 +108,10 @@ func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘 writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Backup.\n---\n\n# Demo\n\nBody.\n") writeFile(t, filepath.Join(root, "run.py"), "print('hello')\n") writeFile(t, filepath.Join(root, "_meta.json"), "{}\n") + writeFile(t, filepath.Join(root, ".env.example"), "API_KEY=your-api-key\n") for _, relative := range []string{ ".clawhub/origin.json", ".git/config", "node_modules/lib/package.json", - "__pycache__/run.pyc", "scripts/__pycache__/cache.pyc", ".env.example", + "__pycache__/run.pyc", "scripts/__pycache__/cache.pyc", } { writeFile(t, filepath.Join(root, filepath.FromSlash(relative)), "generated\n") } @@ -120,12 +121,12 @@ func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘 if err != nil { t.Fatal(err) } - if len(exported.Files) != 3 || !hasFile(exported.Files, "_meta.json") || len(exported.Warnings) == 0 { + if len(exported.Files) != 4 || !hasFile(exported.Files, "_meta.json") || !hasFile(exported.Files, ".env.example") || len(exported.Warnings) == 0 { t.Fatalf("unexpected filtered files: %#v", exported.Files) } for _, name := range zipNames(t, archive) { if strings.Contains(name, "__pycache__") || strings.Contains(name, "node_modules") || - strings.Contains(name, ".clawhub") || strings.Contains(name, ".git/") || name == ".env.example" { + strings.Contains(name, ".clawhub") || strings.Contains(name, ".git/") { t.Fatalf("backup included local-only data: %s", name) } } diff --git a/internal/resourcelibrary/policy.go b/internal/resourcelibrary/policy.go index 47eb4c937..bdc66adbc 100644 --- a/internal/resourcelibrary/policy.go +++ b/internal/resourcelibrary/policy.go @@ -55,7 +55,8 @@ func rejectedPrivatePath(relative string) string { case ".sqlite", ".db", ".bak", ".session": return "private runtime data" } - if segment == ".env" || strings.HasPrefix(segment, ".env.") || extension == ".env" { + if segment == ".env" || (strings.HasPrefix(segment, ".env.") && + segment != ".env.example" && segment != ".env.sample" && segment != ".env.template") || extension == ".env" { return "environment file" } // 以普通源码命名的 session.ts、key.go 不属于凭证文件。 @@ -94,8 +95,7 @@ func ignoredExportPath(relative string) bool { for _, segment := range strings.Split(strings.ToLower(relative), "/") { switch segment { case ".git", ".hg", ".svn", ".clawhub", "__pycache__", "node_modules", - ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".venv", "venv", - ".env.example", ".env.sample", ".env.template": + ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".venv", "venv": return true } } From cc84e19f9c25530975f5424f5b78d6e7342aee50 Mon Sep 17 00:00:00 2001 From: UVWT <54053135+uvwt@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:53:39 +0800 Subject: [PATCH 5/6] fix(resource-library): back up all managed source files without filtering --- internal/resourcelibrary/export.go | 104 +++-------------------------- 1 file changed, 10 insertions(+), 94 deletions(-) diff --git a/internal/resourcelibrary/export.go b/internal/resourcelibrary/export.go index b565b15fd..6894fe345 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -2,14 +2,12 @@ package resourcelibrary import ( "archive/zip" - "bytes" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" - "regexp" "sort" "strings" @@ -30,22 +28,8 @@ type FileEntry struct { Size int64 `json:"size"` } -var hardcodedSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----`), - regexp.MustCompile(`AKIA[0-9A-Z]{16}`), - regexp.MustCompile(`(?i)(api[_-]?key|access[_-]?token|secret|password|passwd)\s*[:=]\s*['"][A-Za-z0-9+/=_\-]{8,}`), -} - -var devicePathMarkers = []string{ - "/.agentdock/", - `\.agentdock\`, - "AGENTDOCK_HOME", - "SKILL_DATA_DIR", - "PLUGIN_DATA_DIR", -} - // ExportDirectory 把一个已安装的受管目录打成 ZIP。 -// 安装器已经接纳的源码不再经第二套扩展名或目录白名单筛选,只排除缓存并拒绝私有数据。 +// 从已安装的受管目录导出完整资源,不对凭据、缓存或普通文件做隐式剔除。 func ExportDirectory(root, kind, destination string) (Export, error) { if kind != KindSkill && kind != KindPlugin { return Export{}, failed("PACKAGE_KIND_INVALID", "validation", "package kind must be skill or plugin") @@ -58,7 +42,7 @@ func ExportDirectory(root, kind, destination string) (Export, error) { return Export{}, failed("PACKAGE_INVALID", "validation", "managed package must be a regular directory") } - files, warnings, err := collectPortableFiles(root, kind) + files, err := collectPortableFiles(root, kind) if err != nil { return Export{}, err } @@ -69,13 +53,13 @@ func ExportDirectory(root, kind, destination string) (Export, error) { return Export{}, failed("PACKAGE_INVALID", "validation", "managed Plugin export requires plugin.json") } - // 内容摘要必须对应实际备份的文件;原目录可能包含被忽略的缓存与来源元数据。 + // 摘要按实际文件集计算,原生安装器仅忽略操作系统 ZIP 传输元数据。 size, archiveDigest, contentDigest, err := writePortableZip(root, files, destination, archiveLimit(kind)) if err != nil { return Export{}, err } return Export{ - Files: files, Warnings: warnings, Size: size, + Files: files, Size: size, ArchiveDigest: archiveDigest, ContentDigest: contentDigest, }, nil } @@ -89,15 +73,14 @@ func hasFile(files []FileEntry, name string) bool { return false } -func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { +func collectPortableFiles(root, kind string) ([]FileEntry, error) { fsRoot, err := os.OpenRoot(root) if err != nil { - return nil, nil, failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") + return nil, failed("PACKAGE_INVALID", "validation", "managed package directory cannot be opened") } defer fsRoot.Close() files := make([]FileEntry, 0) - warnings := make([]string, 0) var total int64 err = filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error { if walkErr != nil { @@ -117,14 +100,6 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { } return nil } - if ignoredExportPath(relative) { - // 不默默丢掉可能需要重新安装的依赖,向 Cloud 明确报告被排除的路径。 - warnings = append(warnings, relative+" was excluded from backup (local metadata or generated dependencies)") - if entry.IsDir() { - return filepath.SkipDir - } - return nil - } // os.Root 逐段拒绝符号链接替换。WalkDir 看到的类型不够,因为中间目录可能在遍历后被换掉。 info, err := fsRoot.Lstat(relative) if err != nil { @@ -136,15 +111,6 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if err := validateArchiveName(relative); err != nil { return err } - if reason := rejectedPrivatePath(relative); reason != "" { - // 私密文件不能离开用户设备,但也不应该阻断整个 Skill/Plugin 备份。 - // 跳过源文件,不改动本地安装;让用户知道恢复时需重新配置凭证。 - warnings = append(warnings, relative+" was excluded from backup ("+reason+"); reconfigure on restore") - if info.IsDir() { - return filepath.SkipDir - } - return nil - } if info.IsDir() { return nil } @@ -162,67 +128,17 @@ func collectPortableFiles(root, kind string) ([]FileEntry, []string, error) { if len(files)+1 > MaxPackageFiles { return failedf("PACKAGE_INVALID", "validation", "package exceeds %d files", MaxPackageFiles) } - found, scanErr := scanHardcodedSecrets(fsRoot, relative) - if scanErr != nil { - return scanErr - } - warnings = append(warnings, found...) files = append(files, FileEntry{Path: relative, Size: info.Size()}) return nil }) if err != nil { - return nil, nil, err + return nil, err } if len(files) == 0 { - return nil, nil, failed("PACKAGE_INVALID", "validation", "managed package has no portable files") + return nil, failed("PACKAGE_INVALID", "validation", "managed package has no portable files") } sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - return files, warnings, nil -} - -func scanHardcodedSecrets(root *os.Root, relative string) ([]string, error) { - extension := strings.ToLower(filepath.Ext(relative)) - switch extension { - case ".png", ".jpg", ".jpeg", ".gif", ".webp": - return nil, nil - } - file, err := root.Open(relative) - if err != nil { - return nil, failed("PACKAGE_INVALID", "validation", "managed package file cannot be read") - } - defer file.Close() - data, err := io.ReadAll(io.LimitReader(file, maxSecretScanBytes+1)) - if err != nil { - return nil, failed("PACKAGE_INVALID", "validation", "managed package file cannot be read") - } - if bytes.IndexByte(data, 0) >= 0 { - return nil, nil - } - scanned := data - partial := false - if int64(len(data)) > maxSecretScanBytes { - scanned = data[:maxSecretScanBytes] - partial = true - } - text := string(scanned) - warnings := make([]string, 0, 1) - for _, pattern := range hardcodedSecretPatterns { - if pattern.MatchString(text) { - // 警告只指向文件,不回显匹配片段,避免把凭证再写进控制面响应。 - warnings = append(warnings, relative+" may contain a hardcoded credential") - break - } - } - for _, marker := range devicePathMarkers { - if strings.Contains(text, marker) { - warnings = append(warnings, relative+" may contain a device-local path") - break - } - } - if partial { - warnings = append(warnings, relative+" was only scanned for the first 1 MiB") - } - return warnings, nil + return files, nil } func writePortableZip(root string, files []FileEntry, destination string, limit int64) (int64, string, string, error) { @@ -238,7 +154,7 @@ func writePortableZip(root string, files []FileEntry, destination string, limit } writer := zip.NewWriter(output) failedWrite := false - // 使用安装器相同的路径、权限和内容序列计算 digest,避免缓存过滤后摘要仍指向原目录。 + // 使用安装器相同的路径、权限和内容序列计算 digest。 contentHash := sha256.New() for _, file := range files { info, err := fsRoot.Lstat(file.Path) From daf55ff657a7781a2a63dd753bd0f56ab659fc39 Mon Sep 17 00:00:00 2001 From: x x Date: Wed, 14 Oct 2026 16:25:27 +0800 Subject: [PATCH 6/6] fix(resource-library): preserve credentials and allow native restore --- docs/cloud-resource-library-device.md | 4 +- internal/plugin/package.go | 3 - internal/resourcelibrary/export_test.go | 242 +++++++++++------------- internal/resourcelibrary/policy.go | 76 +------- internal/skill/install_test.go | 18 +- internal/skill/skilldoc.go | 3 - 6 files changed, 129 insertions(+), 217 deletions(-) diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index 1bb7fb401..f8e31051e 100644 --- a/docs/cloud-resource-library-device.md +++ b/docs/cloud-resource-library-device.md @@ -6,7 +6,7 @@ - Skill 仍是纯 `SKILL.md` 资源包,沿用现有本地/URL安装、摘要、文件数和 128 MiB 字节限制。Cloud 修订号不写入 Skill frontmatter。 - Plugin 仍是支持 Portable/OpenAI/Claude 的包,保留 `plugin_manage validate` / `install` / `update` 和内容绑定的 `review_token`;压缩包 64 MiB、提取后 256 MiB。Cloud 修订号不覆盖插件 manifest 原版号;同名非-local 版本不同内容仍拒绝。 -- **受管 Skill/Plugin 默认按实际安装的资源文件导出**,不再用额外的目录/扩展名白名单拒绝正常源码。设备打包时自动排除 Env 真实值、私钥/凭证、session、Cookie、skill-data 和其他私有运行数据,不修改用户本地文件;保留 `.env.example` 等无真实凭据的配置模板;忽略 Git/ClawHub 来源元数据、Python/Node 缓存与本地依赖目录。通过文件名/路径判断的排除项会明确产生告警,恢复时可能需要重新安装依赖或配置环境;Cloud 和设备入站仍拒绝带有这些私密文件的恶意 ZIP。允许文件内疑似硬编码凭证仅提示风险,不能保证自动识别所有秘密。Plugin 附属 Skill 随完整 Plugin 导出。 +- **设备备份保留受管 Skill/Plugin 的完整常规文件**,包括用户提供的配置、依赖、缓存、凭据等内容;不按文件名、目录名、扩展名或内容自动剔除,不进行敏感内容扫描,也不生成此类提示。只沿用原生安装器一致的 ZIP 传输元数据忽略规则,保留路径穿越、符号链接、文件类型和容量安全门禁。Cloud 当前可读取 ZIP,不提供端到端加密。 - 本地检验需要负责 ZIP 路径穿越、符号链接、文件类型、文件数量、解包大小、插件命令/MCP 配置风险;Cloud 只能展示报告和摘要,不能代替设备安装器。 - 远程操作必须通过用户在 Cloud 明确确认的、绑定 node_id/操作/包摘要/短有效期的一次性挑战。**现有 `review_token` 是确定性内容标识,不是设备授权或用户确认**;安装前必须复用现有 `InstallReviewedSource` / `UpdateReviewedSource`,验证失败禁止安装。 - 不把大文件塞进 WebSocket `runtime.request`,只用其传控制与结果;压缩包走限额、鉴权的专用流式传输,支持离线失败、不后台补装;兼容未声明相应能力的旧 AgentDock,旧客户端 Runtime 仍只读。 @@ -19,7 +19,7 @@ 已落地的是设备本地控制面,不是 Cloud 备份或远程安装发布: - `POST /internal/runtime/resource-library` 只接收不超过 64 KiB 的控制 JSON。未实现 `ResourceLibraryRuntime` 的旧 Runtime 返回 `RESOURCE_LIBRARY_UNSUPPORTED`。 -- `export_prepare` 只导出已安装的受管 Skill/Plugin,遵循原生安装器的包结构并拒绝符号链接、特殊文件;私有运行时文件与缓存在打包前自动排除并返回告警;普通源码目录、文件名、扩展名不受额外限制;内容摘要按实际 ZIP 文件集计算,供后续原生安装器校验。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 +- `export_prepare` 只导出已安装的受管 Skill/Plugin,遵循原生安装器的包结构并拒绝符号链接、特殊文件;资源文件按实际目录打包;普通源码目录、文件名、扩展名不受额外限制;内容摘要按实际 ZIP 文件集计算,供后续原生安装器校验。`download_grant` 仍是短时本地句柄,响应不带 ZIP。 - `export_upload` 只接受 `{action,upload_url,download_grant}`。它复用该 grant,再次核对 grant 未过期、ZIP 大小和归档摘要未变,然后用已配对 Device Token 以 `PUT`、`Content-Type: application/zip`、`Content-Length` 为实际大小,把文件流式送到 `https://{paired-endpoint}/v1/nodes/library/transfer/{ticket}`。请求结束(成功、失败或超时)后销毁 grant 和 ZIP,同一 grant 不能第二次上传。响应只返回 `size`、`archive_digest` 和 `content_digest`。 - `install_prepare` 只用已配对 Nexus 的 Device Token,通过现有出站 HTTPS 客户端下载候选 ZIP。其他 Origin、本机和私网地址拒绝。`package_digest` 是可选的预期内容摘要:Cloud 不能重算 Skill/Plugin 原生 digest。原生 validate 成功后,设备把实际 `package_digest` 绑定到一次性挑战;调用方提供了就必须匹配。审核报告和 Plugin `review_token` 仍由原生安装器产生。 - `install_commit` 仍必须精确匹配确认时的 node、操作、归档摘要、实际 `package_digest`、Plugin `review_token` 和一次性挑战,然后再调用现有 Skill 安装或 Plugin `InstallReviewedSource` / `UpdateReviewedSource`。字段不匹配不消耗挑战。 diff --git a/internal/plugin/package.go b/internal/plugin/package.go index 37a144c19..53282b7e1 100644 --- a/internal/plugin/package.go +++ b/internal/plugin/package.go @@ -297,9 +297,6 @@ func validatePackageTree(root string) ([]string, error) { if !info.Mode().IsRegular() { return pluginError("PLUGIN_PACKAGE_INVALID", "package.file_type", fmt.Errorf("special files are not allowed: %s", relative)) } - if entry.Name() == ".env" { - return pluginError("PLUGIN_PACKAGE_INVALID", "package.secret_file", errors.New(".env files are not allowed in Plugin packages")) - } return nil }) if err != nil { diff --git a/internal/resourcelibrary/export_test.go b/internal/resourcelibrary/export_test.go index 8980326d4..678dc98da 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -3,55 +3,70 @@ package resourcelibrary import ( "archive/zip" "context" + "io" "os" "path/filepath" - "strings" + "runtime" "testing" ) -func TestExportDirectory_自动排除隐私文件而不修改本地Skill(t *testing.T) { +func TestExportDirectory_完整保留配置文件(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export demo.\n---\n\n# Demo\n\npassword: \"supersecretvalue\"\n") - writeFile(t, filepath.Join(root, "references", "guide.md"), "Use a relative path.\n") - writeFile(t, filepath.Join(root, ".env"), "TOKEN=real\n") - writeFile(t, filepath.Join(root, "credentials", "token.json"), `{"token":"real"}`) - - archive := filepath.Join(t.TempDir(), "skill.zip") + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n") + files := map[string]string{ + ".env": "API_TOKEN=local\n", "credentials/token.json": "{\"token\":\"sample\"}", + "secrets/private.key": "private-key-fixture", + "config.sqlite": "fixture-data", "README.md": "Normal document", + } + for name, content := range files { + writeFile(t, filepath.Join(root, filepath.FromSlash(name)), content) + } + archive := filepath.Join(t.TempDir(), "backup.zip") exported, err := ExportDirectory(root, KindSkill, archive) if err != nil { t.Fatal(err) } - if exported.Size == 0 || exported.ArchiveDigest == "" || exported.ContentDigest == "" { - t.Fatalf("export = %#v", exported) - } - if !hasFile(exported.Files, "SKILL.md") || !hasFile(exported.Files, "references/guide.md") { - t.Fatalf("files = %#v", exported.Files) + if len(exported.Warnings) != 0 { + t.Fatalf("unexpected warnings: %#v", exported.Warnings) } - if hasFile(exported.Files, ".env") || hasFile(exported.Files, "credentials/token.json") { - t.Fatalf("private data appeared in export manifest: %#v", exported.Files) - } - if len(exported.Warnings) == 0 { - t.Fatal("export must explain that private data was omitted") - } - warnings := strings.Join(exported.Warnings, "\n") - if !strings.Contains(warnings, ".env") || !strings.Contains(warnings, "credentials") || - strings.Contains(warnings, "TOKEN=real") || strings.Contains(warnings, `"token":"real"`) { - t.Fatalf("warnings should name excluded paths, never expose values: %s", warnings) - } - for _, name := range zipNames(t, archive) { - if name == ".env" || strings.HasPrefix(name, "credentials/") { - t.Fatalf("exported ZIP contains private data: %s", name) - } - } - if actual, err := os.ReadFile(filepath.Join(root, ".env")); err != nil || string(actual) != "TOKEN=real\n" { - t.Fatalf("local .env was modified: %q %v", actual, err) + if len(exported.Files) != 1+len(files) { + t.Fatalf("lost files: %#v", exported.Files) } if err := InspectArchive(archive, KindSkill); err != nil { - t.Fatalf("exported ZIP must pass device safety inspection: %v", err) + t.Fatal(err) } preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) if err != nil || !preview.Valid { - t.Fatalf("exported ZIP cannot be restored: %#v %v", preview, err) + t.Fatalf("native Skill validator rejected complete backup: %#v %v", preview, err) + } + zipReader, err := zip.OpenReader(archive) + if err != nil { + t.Fatal(err) + } + defer zipReader.Close() + for name, content := range files { + if !hasFile(exported.Files, name) { + t.Fatalf("missing file %s", name) + } + found := false + for _, f := range zipReader.File { + if f.Name != name { + continue + } + found = true + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil || string(data) != content { + t.Fatalf("%s content mismatch: %v", name, err) + } + } + if !found { + t.Fatalf("zip lacks %s", name) + } } } @@ -90,7 +105,7 @@ func TestExportDirectory_拒绝符号链接但允许自定义目录(t *testing.T t.Fatal(err) } for _, file := range zipReader.File { - if file.Name == "bin/tool" && file.Mode().Perm()&0o111 == 0 { + if runtime.GOOS != "windows" && file.Name == "bin/tool" && file.Mode().Perm()&0o111 == 0 { t.Errorf("export lost executable bit: %s %v", file.Name, file.Mode()) } } @@ -103,100 +118,73 @@ func TestExportDirectory_拒绝符号链接但允许自定义目录(t *testing.T } } -func TestExportDirectory_忽略本地缓存但保留普通元数据并保持摘要一致(t *testing.T) { +func TestExportDirectory_源码和依赖目录都保留(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Backup.\n---\n\n# Demo\n\nBody.\n") - writeFile(t, filepath.Join(root, "run.py"), "print('hello')\n") - writeFile(t, filepath.Join(root, "_meta.json"), "{}\n") - writeFile(t, filepath.Join(root, ".env.example"), "API_KEY=your-api-key\n") - for _, relative := range []string{ - ".clawhub/origin.json", ".git/config", "node_modules/lib/package.json", - "__pycache__/run.pyc", "scripts/__pycache__/cache.pyc", - } { - writeFile(t, filepath.Join(root, filepath.FromSlash(relative)), "generated\n") + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n") + files := []string{ + "run.py", ".env.example", "_meta.json", ".clawhub/origin.json", + ".git/config", "node_modules/lib/package.json", "__pycache__/run.pyc", + "scripts/__pycache__/cache.pyc", } - - archive := filepath.Join(t.TempDir(), "skill.zip") + for _, name := range files { + writeFile(t, filepath.Join(root, filepath.FromSlash(name)), "content\n") + } + archive := filepath.Join(t.TempDir(), "backup.zip") exported, err := ExportDirectory(root, KindSkill, archive) if err != nil { t.Fatal(err) } - if len(exported.Files) != 4 || !hasFile(exported.Files, "_meta.json") || !hasFile(exported.Files, ".env.example") || len(exported.Warnings) == 0 { - t.Fatalf("unexpected filtered files: %#v", exported.Files) + if len(exported.Warnings) != 0 { + t.Fatalf("unexpected warnings: %#v", exported.Warnings) + } + if len(exported.Files) != len(files)+1 { + t.Fatalf("unexpected files: %#v", exported.Files) } - for _, name := range zipNames(t, archive) { - if strings.Contains(name, "__pycache__") || strings.Contains(name, "node_modules") || - strings.Contains(name, ".clawhub") || strings.Contains(name, ".git/") { - t.Fatalf("backup included local-only data: %s", name) + for _, name := range files { + if !hasFile(exported.Files, name) { + t.Fatalf("file omitted: %s", name) } } - preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) - if err != nil || !preview.Valid { - t.Fatalf("snapshot digest differs from native installer: preview=%#v err=%v", preview, err) + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) } } -func TestExportDirectory_敏感命名目录仍可保存正常资源文件(t *testing.T) { +func TestExportDirectory_目录名称不影响备份(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: demo\n---\n\n# Demo\n") - writeFile(t, filepath.Join(root, "credentials", "README.md"), "Document non-secret configuration.\n") - writeFile(t, filepath.Join(root, "secrets", "examples", "mock.json"), "{}\n") - writeFile(t, filepath.Join(root, "credentials", "token.json"), "DO_NOT_BACK_UP") - writeFile(t, filepath.Join(root, "secrets", "private.key"), "DO_NOT_BACK_UP") + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n") + names := []string{"credentials/README.md", "credentials/token.json", "secrets/private.key", "secrets/examples/mock.json"} + for _, name := range names { + writeFile(t, filepath.Join(root, filepath.FromSlash(name)), "fixture") + } archive := filepath.Join(t.TempDir(), "backup.zip") exported, err := ExportDirectory(root, KindSkill, archive) if err != nil { t.Fatal(err) } - if !hasFile(exported.Files, "credentials/README.md") || !hasFile(exported.Files, "secrets/examples/mock.json") { - t.Fatalf("non-secret files were omitted: %#v", exported.Files) - } - if hasFile(exported.Files, "credentials/token.json") || hasFile(exported.Files, "secrets/private.key") { - t.Fatalf("private files were included: %#v", exported.Files) + for _, name := range names { + if !hasFile(exported.Files, name) { + t.Fatalf("file omitted: %s", name) + } } if err := InspectArchive(archive, KindSkill); err != nil { t.Fatal(err) } - preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) - if err != nil || !preview.Valid { - t.Fatalf("filtered Skill cannot be restored: %#v %v", preview, err) - } } -func TestExportDirectory_不同私有路径自动排除且不影响普通源码(t *testing.T) { - for _, secret := range []string{ - ".env", ".env.production", "credentials/token.json", "secret.json", "private_key.yaml", - ".ssh/config", "state.sqlite", "auth-session.json", "token.json", "credentials.csv", - "secrets/key.pem", "data/cookies.json", "data/.env", "data/skill-data/state.json", - } { - t.Run(secret, func(t *testing.T) { +func TestExportDirectory_敏感文件名不触发额外校验(t *testing.T) { + for _, name := range []string{".env", "credentials/token.json", "secret.json", "private_key.yaml", "state.sqlite", "sessions/state.json"} { + t.Run(name, func(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Export.\n---\n\n# Demo\n") - writeFile(t, filepath.Join(root, "session.ts"), "export const start = true;\n") - source := filepath.Join(root, filepath.FromSlash(secret)) - writeFile(t, source, "private content") - archive := filepath.Join(t.TempDir(), "skill.zip") - exported, err := ExportDirectory(root, KindSkill, archive) + writeFile(t, filepath.Join(root, "SKILL.md"), "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, filepath.FromSlash(name)), "fixture") + exported, err := ExportDirectory(root, KindSkill, filepath.Join(t.TempDir(), "backup.zip")) if err != nil { - t.Fatalf("backup should skip private data: %v", err) - } - if hasFile(exported.Files, secret) || !hasFile(exported.Files, "session.ts") { - t.Fatalf("wrong export manifest: %#v", exported.Files) - } - for _, name := range zipNames(t, archive) { - if name == secret { - t.Fatalf("private data was uploaded: %s", name) - } - } - if len(exported.Warnings) == 0 { - t.Fatal("omitted private files need a visible warning") - } - if actual, err := os.ReadFile(source); err != nil || string(actual) != "private content" { - t.Fatalf("original file was modified: %q %v", actual, err) - } - if err := InspectArchive(archive, KindSkill); err != nil { t.Fatal(err) } + if !hasFile(exported.Files, name) || len(exported.Warnings) != 0 { + t.Fatalf("unexpected filtering: %#v", exported) + } }) } } @@ -212,30 +200,27 @@ func TestExportDirectory_Skill只允许根目录Manifest(t *testing.T) { } } -func TestExportDirectory_Plugin也自动排除隐私文件(t *testing.T) { +func TestExportDirectory_Plugin完整保留配置(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "plugin.json"), `{"name":"demo-plugin","version":"1.0.0","description":"Demo"}`) - writeFile(t, filepath.Join(root, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: Plugin skill.\n---\n\n# Demo\n\nBody.\n") - writeFile(t, filepath.Join(root, "notes.pem"), "-----BEGIN PRIVATE KEY-----\n") - writeFile(t, filepath.Join(root, "skills", "demo-skill", ".env"), "API_KEY=secret\n") + writeFile(t, filepath.Join(root, "plugin.json"), "{\"name\":\"demo-plugin\",\"version\":\"1.0.0\"}") + writeFile(t, filepath.Join(root, "skills", "demo-skill", "SKILL.md"), "---\nname: demo-skill\ndescription: Plugin skill.\n---\n\n# Demo\n") + writeFile(t, filepath.Join(root, ".env"), "API_KEY=fixture") + writeFile(t, filepath.Join(root, "notes.pem"), "fixture-key") archive := filepath.Join(t.TempDir(), "plugin.zip") exported, err := ExportDirectory(root, KindPlugin, archive) if err != nil { t.Fatal(err) } - if hasFile(exported.Files, "notes.pem") || hasFile(exported.Files, "skills/demo-skill/.env") { - t.Fatalf("private Plugin files were included: %#v", exported.Files) - } - if len(exported.Warnings) < 2 { - t.Fatalf("expected omitted-file warnings, got: %#v", exported.Warnings) + if !hasFile(exported.Files, ".env") || !hasFile(exported.Files, "notes.pem") || len(exported.Warnings) != 0 { + t.Fatalf("plugin filtering: %#v", exported) } if err := InspectArchive(archive, KindPlugin); err != nil { t.Fatal(err) } - manager := newPluginManager(t, filepath.Join(t.TempDir(), "agentdock-home")) + manager := newPluginManager(t, filepath.Join(t.TempDir(), "home")) review := manager.ValidateSource(context.Background(), archive) if !review.Valid || review.PackageDigest != exported.ContentDigest { - t.Fatalf("filtered Plugin must still pass native validation: %#v, exported=%s", review, exported.ContentDigest) + t.Fatalf("native Plugin validator rejected complete backup: %#v (digest %s)", review, exported.ContentDigest) } } @@ -264,28 +249,23 @@ func TestExportDirectory_Plugin保留原生安装器支持的文件(t *testing.T } } -func TestInspectArchive_拒绝路径穿越符号链接和秘密文件(t *testing.T) { - traversal := filepath.Join(t.TempDir(), "traversal.zip") - writeRawZip(t, traversal, map[string]string{"../evil.txt": "owned"}) - if err := InspectArchive(traversal, KindSkill); err == nil { - t.Fatal("inspect accepted a traversal entry") - } - - secret := filepath.Join(t.TempDir(), "secret.zip") - writeRawZip(t, secret, map[string]string{".env": "TOKEN=real", "SKILL.md": "---\nname: demo-skill\n"}) - if err := InspectArchive(secret, KindSkill); err == nil { - t.Fatal("inspect accepted .env") +func TestInspectArchive_拒绝危险结构但不拒绝配置文件(t *testing.T) { + bad := filepath.Join(t.TempDir(), "traversal.zip") + writeRawZip(t, bad, map[string]string{"../evil.txt": "fixture"}) + if err := InspectArchive(bad, KindSkill); err == nil { + t.Fatal("accepted traversal") } - link := filepath.Join(t.TempDir(), "link.zip") - writeSymlinkZip(t, link, "SKILL.md", "outside") + writeSymlinkZip(t, link, "link", "outside") if err := InspectArchive(link, KindSkill); err == nil { - t.Fatal("inspect accepted a symlink") - } - - valid := filepath.Join(t.TempDir(), "valid.zip") - writeRawZip(t, valid, map[string]string{"SKILL.md": "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n"}) - if err := InspectArchive(valid, KindSkill); err != nil { + t.Fatal("accepted symlink") + } + good := filepath.Join(t.TempDir(), "good.zip") + writeRawZip(t, good, map[string]string{ + "SKILL.md": "---\nname: demo-skill\ndescription: Demo.\n---\n\n# Demo\n", + ".env": "fixture", "credentials/token.json": "fixture", "secrets/private.key": "fixture", + }) + if err := InspectArchive(good, KindSkill); err != nil { t.Fatal(err) } } diff --git a/internal/resourcelibrary/policy.go b/internal/resourcelibrary/policy.go index bdc66adbc..9f1a142a2 100644 --- a/internal/resourcelibrary/policy.go +++ b/internal/resourcelibrary/policy.go @@ -4,7 +4,6 @@ import ( "archive/zip" "io" "os" - "path" "strings" ) @@ -19,7 +18,6 @@ const ( PluginExtractLimit int64 = 256 << 20 MaxPackageFiles = 10000 maxControlBody = 64 * 1024 - maxSecretScanBytes int64 = 1 << 20 ) func archiveLimit(kind string) int64 { @@ -36,75 +34,9 @@ func extractLimit(kind string) int64 { return SkillExtractLimit } -// rejectedPrivatePath 拒绝私人运行时数据和密钥文件名。 -// 这是路径规则,不是内容扫描;内容里的疑似凭证只产生警告。 -func rejectedPrivatePath(relative string) string { - relative = strings.ToLower(path.Clean(relative)) - if relative == "." { - return "" - } - for _, segment := range strings.Split(relative, "/") { - if segment == "" || segment == "." { - continue - } - extension := path.Ext(segment) - stem := strings.TrimSuffix(segment, extension) - switch extension { - case ".pem", ".key", ".p12", ".pfx", ".keystore", ".jks": - return "key material" - case ".sqlite", ".db", ".bak", ".session": - return "private runtime data" - } - if segment == ".env" || (strings.HasPrefix(segment, ".env.") && - segment != ".env.example" && segment != ".env.sample" && segment != ".env.template") || extension == ".env" { - return "environment file" - } - // 以普通源码命名的 session.ts、key.go 不属于凭证文件。 - // credentials/、secrets/ 可能包含正常资源文件,目录名不作为整目录拒绝的依据; - // 其中明确的密钥、Token、凭证文件名仍按下方规则过滤。 - switch segment { - case "cookie", "cookies", "session", "sessions", "secret", - "credential", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", - "key", "privatekey", "private_key", "skill-data", ".skill-data", - ".agentdock", ".ssh", ".aws", ".kube": - return "private runtime data" - } - switch extension { - case ".json", ".yaml", ".yml", ".toml", ".ini", ".txt", ".csv", ".xml", ".conf", ".cfg", ".properties": - switch stem { - case "cookie", "cookies", "session", "sessions", "secret", "secrets", - "credential", "credentials", "privatekey", "private_key", "key", - "token", "tokens", "access_token", "api_key", "password", "passwords": - return "private runtime data" - } - if strings.HasSuffix(stem, "-key") || strings.HasSuffix(stem, "_key") || - strings.HasSuffix(stem, "-secret") || strings.HasSuffix(stem, "_secret") || - strings.HasSuffix(stem, "-cookie") || strings.HasSuffix(stem, "_cookie") || - strings.HasSuffix(stem, "-session") || strings.HasSuffix(stem, "_session") { - return "private runtime data" - } - } - } - return "" -} - -// ignoredExportPath 是与源码分离的缓存、安装来源元数据和环境示例文件。 -// 它们不属于可恢复的包内容,不应因本机产生的缓存而阻断整份备份。 -// 不忽略用户脚本、任意目录或普通点文件;敏感数据由 rejectedPrivatePath 明确拒绝。 -func ignoredExportPath(relative string) bool { - for _, segment := range strings.Split(strings.ToLower(relative), "/") { - switch segment { - case ".git", ".hg", ".svn", ".clawhub", "__pycache__", "node_modules", - ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".venv", "venv": - return true - } - } - return false -} - // InspectArchive 在把候选 ZIP 交给原生安装器之前拒绝明显危险的包。 // 不对入站包施加源码扩展名白名单;Skill/Plugin 能否安装仍由原生安装器决定, -// 这里负责路径穿越、符号链接、特殊文件、私钥文件名、文件数和解压大小。 +// 这里负责路径穿越、符号链接、特殊文件、文件数和解压大小。 func InspectArchive(archivePath, kind string) error { reader, err := zip.OpenReader(archivePath) if err != nil { @@ -127,18 +59,12 @@ func InspectArchive(archivePath, kind string) error { return err } if file.FileInfo().IsDir() { - if reason := rejectedPrivatePath(name); reason != "" { - return failedf("PACKAGE_PRIVATE_DATA", "validation", "ZIP directory contains %s: %s", reason, name) - } continue } mode := file.Mode() if !mode.IsRegular() { return failedf("PACKAGE_INVALID", "validation", "ZIP special file is not allowed: %s", file.Name) } - if reason := rejectedPrivatePath(name); reason != "" { - return failedf("PACKAGE_PRIVATE_DATA", "validation", "ZIP entry contains %s: %s", reason, name) - } files++ if files > MaxPackageFiles { return failedf("PACKAGE_INVALID", "validation", "package exceeds %d files", MaxPackageFiles) diff --git a/internal/skill/install_test.go b/internal/skill/install_test.go index d54c1a027..bd99ed561 100644 --- a/internal/skill/install_test.go +++ b/internal/skill/install_test.go @@ -142,7 +142,7 @@ func TestInstallInvalidCandidateLeavesCurrentContentUnchanged(t *testing.T) { t.Fatal(err) } - invalid := writeSkillSource(t, "demo-skill", "Invalid", map[string]string{".env": "SECRET=value\n"}) + invalid := writeSkillSource(t, "demo-skill", "Invalid", map[string]string{"agentdock.yaml": "legacy\n"}) if _, err := manager.Install(context.Background(), InstallRequest{Source: invalid}); err == nil { t.Fatal("invalid update succeeded") } @@ -159,11 +159,10 @@ func TestInstallInvalidCandidateLeavesCurrentContentUnchanged(t *testing.T) { } } -func TestValidateRejectsLegacyManifestAndPrivateEnv(t *testing.T) { +func TestValidateRejectsReservedManifests(t *testing.T) { manager := newManagerForTest(t) for name, extra := range map[string]map[string]string{ "legacy manifest": {"agentdock.yaml": "legacy\n"}, - "private env": {".env": "SECRET=value\n"}, "legacy receipt": {".agentdock-install.json": "{}\n"}, } { t.Run(name, func(t *testing.T) { @@ -179,6 +178,19 @@ func TestValidateRejectsLegacyManifestAndPrivateEnv(t *testing.T) { } } +func TestInstallPreservesEnvironmentFile(t *testing.T) { + manager := newManagerForTest(t) + source := writeSkillSource(t, "demo-skill", "Demo", map[string]string{".env": "SECRET=fixture-value\n"}) + result, err := manager.Install(context.Background(), InstallRequest{Source: source}) + if err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(result.Path, ".env")) + if err != nil || string(actual) != "SECRET=fixture-value\n" { + t.Fatalf("installed environment file does not match: %q %v", actual, err) + } +} + func TestInstallRejectsFIFOWithoutTryingToHashIt(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows does not provide POSIX FIFO semantics for this regression test") diff --git a/internal/skill/skilldoc.go b/internal/skill/skilldoc.go index bc7897477..3062baf49 100644 --- a/internal/skill/skilldoc.go +++ b/internal/skill/skilldoc.go @@ -114,9 +114,6 @@ func ValidatePackage(packageDir string) error { return packageError(ErrInvalidPackage, "package.file_type", errors.New("special files are not allowed in Skill packages")) } } - if !entry.IsDir() && entry.Name() == ".env" { - return packageError(ErrInvalidPackage, "package.secret_file", errors.New(".env files are not allowed in Skill packages; store credentials in the managed Skill environment")) - } if !entry.IsDir() && entry.Name() == ".agentdock-install.json" { return packageError(ErrInvalidPackage, "package.reserved_file", errors.New(".agentdock-install.json is reserved legacy AgentDock metadata and is not valid Skill package content")) }