Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/cloud-resource-library-device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 仍只读。
Expand All @@ -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`。字段不匹配不消耗挑战。
Expand Down
3 changes: 0 additions & 3 deletions internal/plugin/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
141 changes: 39 additions & 102 deletions internal/resourcelibrary/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -140,100 +121,55 @@ 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))
}
total += info.Size()
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
Expand All @@ -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
}
Loading
Loading