diff --git a/docs/cloud-resource-library-device.md b/docs/cloud-resource-library-device.md index 7a9e287ae..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 版本不同内容仍拒绝。 -- **只允许导出重新安装所需的可移植源码快照**,排除所有 Env 真实值、MCP 环境、skill-data、私钥/凭证、session、Cookie、active tasks、缓存、设备绝对路径;即便允许文件有硬编码凭证风险,也应预览内容清单与风险提示。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,使用递归可移植白名单,拒绝符号链接、特殊文件和私人运行时文件名,并给出硬编码凭证风险提示。`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.go b/internal/resourcelibrary/export.go index 793d40c38..6894fe345 100644 --- a/internal/resourcelibrary/export.go +++ b/internal/resourcelibrary/export.go @@ -2,17 +2,19 @@ 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 @@ -26,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") @@ -54,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 } @@ -65,16 +53,13 @@ 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)) + // 摘要按实际文件集计算,原生安装器仅忽略操作系统 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 } @@ -88,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 { @@ -124,13 +108,10 @@ 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 reason := rejectedPrivatePath(relative); reason != "" { - return failedf("PACKAGE_PRIVATE_DATA", "validation", "export refused %s: %s", reason, relative) + if err := validateArchiveName(relative); err != nil { + return err } 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 +121,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)) } @@ -150,90 +128,48 @@ 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 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 + return nil, failed("PACKAGE_INVALID", "validation", "managed package has no portable files") } - 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 + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, 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 +180,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..678dc98da 100644 --- a/internal/resourcelibrary/export_test.go +++ b/internal/resourcelibrary/export_test.go @@ -2,45 +2,75 @@ package resourcelibrary import ( "archive/zip" + "context" + "io" "os" "path/filepath" + "runtime" "testing" ) -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\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") - - 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") + 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", } - - os.Remove(filepath.Join(root, ".env")) - exported, err := ExportDirectory(root, KindSkill, destination) + 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 len(exported.Warnings) != 0 { + t.Fatalf("unexpected warnings: %#v", exported.Warnings) + } + if len(exported.Files) != 1+len(files) { + t.Fatalf("lost files: %#v", exported.Files) } - if !hasFile(exported.Files, "SKILL.md") || !hasFile(exported.Files, "references/guide.md") { - t.Fatalf("files = %#v", exported.Files) + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) } - if len(exported.Warnings) == 0 { - t.Fatal("hardcoded credential warning missing") + preview, err := validateSkillArchive(t, archive, exported.ArchiveDigest, "demo-skill", exported.ContentDigest) + if err != nil || !preview.Valid { + t.Fatalf("native Skill validator rejected complete backup: %#v %v", preview, err) } - names := zipNames(t, destination) - for _, name := range names { - if name == ".env" || name == ".pem" { - t.Fatalf("exported zip contains private file %s", name) + 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) } } } -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 +82,110 @@ 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 runtime.GOOS != "windows" && 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: 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", + } + 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.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 files { + if !hasFile(exported.Files, name) { + t.Fatalf("file omitted: %s", name) + } + } + if err := InspectArchive(archive, KindSkill); err != nil { + t.Fatal(err) + } +} + +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") + 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) + } + 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) + } +} + +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: 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.Fatal(err) + } + if !hasFile(exported.Files, name) || len(exported.Warnings) != 0 { + t.Fatalf("unexpected filtering: %#v", exported) + } + }) } } @@ -68,38 +200,72 @@ 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, "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, "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, ".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, ".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(), "home")) + review := manager.ValidateSource(context.Background(), archive) + if !review.Valid || review.PackageDigest != exported.ContentDigest { + t.Fatalf("native Plugin validator rejected complete backup: %#v (digest %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"}) - if err := InspectArchive(traversal, KindSkill); err == nil { - t.Fatal("inspect accepted a traversal entry") +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) } - - 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") + 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) { + 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") + t.Fatal("accepted 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 { + 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 ea99fad78..9f1a142a2 100644 --- a/internal/resourcelibrary/policy.go +++ b/internal/resourcelibrary/policy.go @@ -4,11 +4,7 @@ import ( "archive/zip" "io" "os" - "path" "strings" - "unicode" - - "github.com/uvwt/agentdock/internal/skillspec" ) const ( @@ -22,7 +18,6 @@ const ( PluginExtractLimit int64 = 256 << 20 MaxPackageFiles = 10000 maxControlBody = 64 * 1024 - maxSecretScanBytes int64 = 1 << 20 ) func archiveLimit(kind string) int64 { @@ -39,160 +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" - } - if segment == ".env" || strings.HasPrefix(segment, ".env.") || extension == ".env" { - return "environment file" - } - switch stem { - case "cookie", "cookies", "session", "sessions", "secret", "secrets", - "credential", "credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", - "key", "privatekey", "private_key": - 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" - } - } - 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 { - 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 - } -} - // InspectArchive 在把候选 ZIP 交给原生安装器之前拒绝明显危险的包。 -// 扩展名白名单只用于导出;入站包仍由 Skill/Plugin 安装器决定能不能安装, -// 这里负责路径穿越、符号链接、特殊文件、私钥文件名、文件数和解压大小。 +// 不对入站包施加源码扩展名白名单;Skill/Plugin 能否安装仍由原生安装器决定, +// 这里负责路径穿越、符号链接、特殊文件、文件数和解压大小。 func InspectArchive(archivePath, kind string) error { reader, err := zip.OpenReader(archivePath) if err != nil { @@ -215,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")) }