返回 DeepSeek-Reasonix
write_access.go
根目录 / internal / config / write_access.go
1 package config
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "slices"
8 "strings"
9
10 "github.com/BurntSushi/toml"
11
12 fileencoding "reasonix/internal/fileutil/encoding"
13 "reasonix/internal/permission"
14 "reasonix/internal/sandbox"
15 )
16
17 // PersistProjectWriteAccess updates [permissions].allow and [sandbox].allow_write
18 // in one locked, validated, atomic write. permRule may be empty when ordinary
19 // permission is already allowed.
20 func PersistProjectWriteAccess(path string, dirs []string, permRule string) error {
21 path = strings.TrimSpace(path)
22 if path == "" {
23 return fmt.Errorf("persist write access: empty config path")
24 }
25 unlock, err := LockConfigFileEdits(path)
26 if err != nil {
27 return err
28 }
29 defer unlock()
30
31 resolved, exists, err := statConfigPath(path)
32 if err != nil {
33 return err
34 }
35 var raw []byte
36 if exists {
37 raw, err = fileencoding.ReadFileUTF8(resolved)
38 if err != nil {
39 return err
40 }
41 }
42 body := string(raw)
43 edit, err := loadForEditStrict(path, true, false)
44 if err != nil {
45 return err
46 }
47
48 home, _ := os.UserHomeDir()
49 allowWrite := append([]string(nil), edit.Sandbox.AllowWrite...)
50 for _, dir := range dirs {
51 formatted := sandbox.FormatConfigWritePath(dir, home)
52 if formatted == "" {
53 continue
54 }
55 if writeRootCovered(allowWrite, formatted, home) {
56 continue
57 }
58 allowWrite = append(allowWrite, formatted)
59 }
60
61 allow := append([]string(nil), edit.Permissions.Allow...)
62 if rule := strings.TrimSpace(permRule); rule != "" {
63 if coveredBy := coveredPermissionRule(allow, rule); coveredBy == "" {
64 allow = pruneCoveredPermissionRules(allow, rule)
65 allow = append(allow, rule)
66 }
67 }
68
69 if body == "" {
70 body = fmt.Sprintf("[permissions]\nallow = %s\n\n[sandbox]\nallow_write = %s\n", renderStringArray(allow), renderStringArray(allowWrite))
71 } else {
72 body = upsertTOMLSectionKey(body, "permissions", "allow", "allow = "+renderStringArray(allow))
73 body = upsertTOMLSectionKey(body, "sandbox", "allow_write", "allow_write = "+renderStringArray(allowWrite))
74 }
75
76 var candidate Config
77 if _, err := toml.Decode(body, &candidate); err != nil {
78 return fmt.Errorf("persist write access: validate updated config: %w", err)
79 }
80 if !slices.Equal(candidate.Permissions.Allow, allow) {
81 return fmt.Errorf("persist write access: validate updated allow: got %v, want %v", candidate.Permissions.Allow, allow)
82 }
83 if !slices.Equal(candidate.Sandbox.AllowWrite, allowWrite) {
84 return fmt.Errorf("persist write access: validate updated allow_write: got %v, want %v", candidate.Sandbox.AllowWrite, allowWrite)
85 }
86 return writeConfigFileResolved(resolved, body, configFilePerm(path))
87 }
88
89 func writeRootCovered(existing []string, candidate, home string) bool {
90 candAbs := expandPersistedWritePath(candidate, home)
91 for _, item := range existing {
92 existAbs := expandPersistedWritePath(item, home)
93 if existAbs == "" || candAbs == "" {
94 if item == candidate {
95 return true
96 }
97 continue
98 }
99 if sandbox.PathWithin(existAbs, candAbs) {
100 return true
101 }
102 }
103 return false
104 }
105
106 func expandPersistedWritePath(raw, home string) string {
107 raw = strings.TrimSpace(raw)
108 if raw == "" {
109 return ""
110 }
111 abs, _, err := sandbox.NormalizeWriteDir(raw, "", home)
112 if err != nil {
113 if filepath.IsAbs(raw) {
114 return filepath.Clean(raw)
115 }
116 return raw
117 }
118 return abs
119 }
120
121 func coveredPermissionRule(existing []string, candidate string) string {
122 for _, item := range existing {
123 if permission.RuleCoversString(item, candidate) {
124 return item
125 }
126 }
127 return ""
128 }
129
130 func pruneCoveredPermissionRules(existing []string, candidate string) []string {
131 out := make([]string, 0, len(existing))
132 for _, item := range existing {
133 if permission.RuleCoversString(candidate, item) && item != candidate {
134 continue
135 }
136 out = append(out, item)
137 }
138 return out
139 }
140
140 lines GO