дȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 д Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
113
internal/skills/safety.go
Normal file
113
internal/skills/safety.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"cursor/internal/appdata"
|
||||
"cursor/internal/securefile"
|
||||
)
|
||||
|
||||
var operationMu sync.Mutex
|
||||
|
||||
func checkManagedPaths(project, name string) error {
|
||||
paths := []string{filepath.Join(project, ".cursor"), skillsDir(project), skillTargetDir(project, name), projectCommandsDir(project)}
|
||||
for _, file := range commandFileNames(name) {
|
||||
paths = append(paths, filepath.Join(projectCommandsDir(project), file))
|
||||
}
|
||||
for _, entry := range paths {
|
||||
info, err := os.Lstat(entry)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("安全保护:拒绝通过符号链接修改 %s", entry)
|
||||
}
|
||||
}
|
||||
target := skillTargetDir(project, name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return filepath.WalkDir(target, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("安全保护:skill 内含符号链接 %s", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func backupBeforeReplace(target string, next []byte) error {
|
||||
previous, err := os.ReadFile(target)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.Equal(previous, next) {
|
||||
return nil
|
||||
}
|
||||
id := fmt.Sprintf("%x", sha256.Sum256([]byte(target)))
|
||||
dir := filepath.Join(appdata.DataRootPath(), "skill-backups", id[:16])
|
||||
if err = securefile.PrivateDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(dir, filepath.Base(target)+"-*.bak")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := file.Name()
|
||||
if err = file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return securefile.Write(name, previous)
|
||||
}
|
||||
|
||||
func ensureNoCustomSkillFiles(project, name string) error {
|
||||
allowed := map[string]bool{}
|
||||
err := fs.WalkDir(builtinFS, path.Join(builtinRoot, name), func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
rel := strings.TrimPrefix(p, path.Join(builtinRoot, name)+"/")
|
||||
allowed[rel] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range retiredSkillFiles[name] {
|
||||
allowed[file] = true
|
||||
}
|
||||
target := skillTargetDir(project, name)
|
||||
if _, err = os.Stat(target); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return filepath.WalkDir(target, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
rel, _ := filepath.Rel(target, p)
|
||||
if !allowed[filepath.ToSlash(rel)] {
|
||||
return fmt.Errorf("skill 含自建文件 %s,已保留;请先备份后手动移除", strings.TrimPrefix(p, target))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
50
internal/skills/safety_test.go
Normal file
50
internal/skills/safety_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUninstallRejectsUnknownSkillAndPreservesCustomFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
custom := filepath.Join(dir, ".cursor", "skills", "user-owned")
|
||||
_ = os.MkdirAll(custom, 0700)
|
||||
file := filepath.Join(custom, "SKILL.md")
|
||||
_ = os.WriteFile(file, []byte("user data"), 0600)
|
||||
if err := Uninstall(dir, "user-owned"); err == nil {
|
||||
t.Fatal("unknown skill accepted")
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
t.Fatal("custom skill deleted")
|
||||
}
|
||||
if err := Install(dir, "fd-engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extra := filepath.Join(skillTargetDir(dir, "fd-engineering"), "my-notes.md")
|
||||
_ = os.WriteFile(extra, []byte("notes"), 0600)
|
||||
if err := Uninstall(dir, "fd-engineering"); err == nil {
|
||||
t.Fatal("custom files silently deleted")
|
||||
}
|
||||
if _, err := os.Stat(extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallPreservesModifiedCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := Install(dir, "fd-engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := filepath.Join(projectCommandsDir(dir), "FD-plan.md")
|
||||
if err := os.WriteFile(command, []byte("custom command"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Uninstall(dir, "fd-engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(command)
|
||||
if err != nil || string(data) != "custom command" {
|
||||
t.Fatal("modified command deleted")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -129,6 +130,16 @@ func Exists(name string) bool {
|
||||
// 并将其 commands/ 模板分发到项目 .cursor/commands/。
|
||||
// 目标已存在时同步为最新内置内容,并清理已退役的内置文件。
|
||||
func Install(projectPath string, name string) error {
|
||||
name = sanitizeSkillName(name)
|
||||
projectPath = strings.TrimSpace(projectPath)
|
||||
operationMu.Lock()
|
||||
defer operationMu.Unlock()
|
||||
if !Exists(name) {
|
||||
return fmt.Errorf("未知的内置 skill")
|
||||
}
|
||||
if err := checkManagedPaths(projectPath, name); err != nil {
|
||||
return err
|
||||
}
|
||||
name = sanitizeSkillName(name)
|
||||
if name == "" || !Exists(name) {
|
||||
return fmt.Errorf("内置 skill 不存在: %s", name)
|
||||
@@ -170,6 +181,9 @@ func installCommands(projectPath string, name string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取命令模板失败: %w", err)
|
||||
}
|
||||
if err := backupBeforeReplace(filepath.Join(commandsDir, fileName), data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(commandsDir, fileName), data, 0o644); err != nil {
|
||||
return fmt.Errorf("写入命令文件失败: %w", err)
|
||||
}
|
||||
@@ -179,6 +193,19 @@ func installCommands(projectPath string, name string) error {
|
||||
|
||||
// Uninstall 移除项目内的 skill 目录与随附的命令文件。不存在时幂等成功。
|
||||
func Uninstall(projectPath string, name string) error {
|
||||
name = sanitizeSkillName(name)
|
||||
projectPath = strings.TrimSpace(projectPath)
|
||||
operationMu.Lock()
|
||||
defer operationMu.Unlock()
|
||||
if !Exists(name) {
|
||||
return fmt.Errorf("未知的内置 skill")
|
||||
}
|
||||
if err := checkManagedPaths(projectPath, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNoCustomSkillFiles(projectPath, name); err != nil {
|
||||
return err
|
||||
}
|
||||
name = sanitizeSkillName(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("skill 名称不能为空")
|
||||
@@ -196,6 +223,14 @@ func Uninstall(projectPath string, name string) error {
|
||||
// 只删除内置清单中的命令文件,不碰用户自建命令。
|
||||
commandsDir := projectCommandsDir(projectPath)
|
||||
for _, fileName := range commandFileNames(name) {
|
||||
expected, _ := builtinFS.ReadFile(path.Join(builtinRoot, name, commandsDirName, fileName))
|
||||
current, readErr := os.ReadFile(filepath.Join(commandsDir, fileName))
|
||||
if readErr == nil && !bytes.Equal(current, expected) {
|
||||
continue
|
||||
}
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
}
|
||||
if err := os.Remove(filepath.Join(commandsDir, fileName)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("移除命令文件失败: %w", err)
|
||||
}
|
||||
@@ -258,6 +293,9 @@ func copyEmbeddedDir(embedDir string, targetDir string, skipDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupBeforeReplace(target, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user