返回 DeepSeek-Reasonix
write_path.go
根目录 / internal / sandbox / write_path.go
1 package sandbox
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 )
10
11 // NormalizeWriteDir expands a user- or model-supplied write directory into an
12 // absolute, symlink-resolved path plus a short display form. raw may be
13 // workspace-relative, start with ~, or contain ${HOME}. Globs are rejected.
14 func NormalizeWriteDir(raw, workDir, home string) (abs, display string, err error) {
15 raw = strings.TrimSpace(raw)
16 if raw == "" {
17 return "", "", fmt.Errorf("write directory is empty")
18 }
19 if writePathHasGlob(raw) {
20 return "", "", fmt.Errorf("write directory %q must be a concrete directory, not a glob", raw)
21 }
22 expanded, err := expandWritePath(raw, home)
23 if err != nil {
24 return "", "", err
25 }
26 if !filepath.IsAbs(expanded) {
27 base := strings.TrimSpace(workDir)
28 if base == "" {
29 base, err = os.Getwd()
30 if err != nil {
31 return "", "", fmt.Errorf("resolve write directory %q: %w", raw, err)
32 }
33 }
34 expanded = filepath.Join(base, expanded)
35 }
36 abs, err = ResolveAbsPath(expanded)
37 if err != nil {
38 return "", "", fmt.Errorf("resolve write directory %q: %w", raw, err)
39 }
40 return abs, DisplayWritePath(abs, home), nil
41 }
42
43 func expandWritePath(raw, home string) (string, error) {
44 if strings.Contains(raw, "${HOME}") {
45 if strings.TrimSpace(home) == "" {
46 return "", fmt.Errorf("write directory %q uses ${HOME} but the home directory is unknown", raw)
47 }
48 raw = strings.ReplaceAll(raw, "${HOME}", home)
49 }
50 if raw == "~" || strings.HasPrefix(raw, "~/") || (runtime.GOOS == "windows" && strings.HasPrefix(raw, `~\`)) {
51 if strings.TrimSpace(home) == "" {
52 return "", fmt.Errorf("write directory %q uses ~ but the home directory is unknown", raw)
53 }
54 if raw == "~" {
55 return home, nil
56 }
57 return filepath.Join(home, raw[2:]), nil
58 }
59 return raw, nil
60 }
61
62 func writePathHasGlob(raw string) bool {
63 return strings.ContainsAny(raw, "*?[")
64 }
65
66 // ResolveAbsPath resolves path to an absolute, cleaned form. Because a write
67 // target need not exist yet, it resolves the deepest existing ancestor with
68 // EvalSymlinks and re-appends the not-yet-existing tail.
69 func ResolveAbsPath(path string) (string, error) {
70 abs, err := filepath.Abs(path)
71 if err != nil {
72 return "", err
73 }
74 abs = filepath.Clean(abs)
75 tail := ""
76 cur := abs
77 for {
78 if real, err := filepath.EvalSymlinks(cur); err == nil {
79 return filepath.Join(real, tail), nil
80 }
81 parent := filepath.Dir(cur)
82 if parent == cur {
83 return abs, nil
84 }
85 tail = filepath.Join(filepath.Base(cur), tail)
86 cur = parent
87 }
88 }
89
90 // DisplayWritePath returns a user-facing form such as ~/.local when abs sits
91 // under home. Other paths stay absolute.
92 func DisplayWritePath(abs, home string) string {
93 abs = canonicalDir(abs)
94 home = canonicalDir(home)
95 if abs == "" {
96 return ""
97 }
98 if home != "" && PathWithin(home, abs) {
99 rel, err := filepath.Rel(home, abs)
100 if err == nil {
101 if rel == "." {
102 return "~"
103 }
104 return "~/" + filepath.ToSlash(rel)
105 }
106 }
107 return abs
108 }
109
110 // FormatConfigWritePath stores home-relative paths as ${HOME}/... and other
111 // paths as cleaned absolute paths.
112 func FormatConfigWritePath(abs, home string) string {
113 abs = canonicalDir(abs)
114 home = canonicalDir(home)
115 if abs == "" {
116 return ""
117 }
118 if home != "" && PathWithin(home, abs) {
119 rel, err := filepath.Rel(home, abs)
120 if err == nil {
121 if rel == "." {
122 return "${HOME}"
123 }
124 return "${HOME}/" + filepath.ToSlash(rel)
125 }
126 }
127 return abs
128 }
129
130 // PathWithin reports whether path is at or below root. Both should be
131 // absolute and cleaned.
132 func PathWithin(root, path string) bool {
133 rel, err := filepath.Rel(root, path)
134 if err != nil {
135 return false
136 }
137 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
138 }
139
140 // CollapseWriteRoots deduplicates directories and drops children already
141 // covered by an ancestor.
142 func CollapseWriteRoots(dirs []string) []string {
143 cleaned := uniqueCleanDirs(dirs)
144 if len(cleaned) < 2 {
145 return cleaned
146 }
147 out := make([]string, 0, len(cleaned))
148 for _, dir := range cleaned {
149 covered := false
150 for _, other := range cleaned {
151 if other == dir {
152 continue
153 }
154 if PathWithin(other, dir) {
155 covered = true
156 break
157 }
158 }
159 if !covered {
160 out = append(out, dir)
161 }
162 }
163 return out
164 }
165
166 func uniqueCleanDirs(dirs []string) []string {
167 seen := make(map[string]bool, len(dirs))
168 out := make([]string, 0, len(dirs))
169 for _, dir := range dirs {
170 dir = filepath.Clean(strings.TrimSpace(dir))
171 if dir == "" || dir == "." || seen[dir] {
172 continue
173 }
174 seen[dir] = true
175 out = append(out, dir)
176 }
177 return out
178 }
179
180 // IsFilesystemRoot reports POSIX /, a Windows drive root, or a UNC share root.
181 func IsFilesystemRoot(abs string) bool {
182 raw := strings.TrimSpace(abs)
183 if raw == "" {
184 return false
185 }
186 if runtime.GOOS == "windows" {
187 // filepath.VolumeName(`\\`) is empty even though the current-drive root
188 // is just as broad as an explicit C:\\ root.
189 if strings.Trim(raw, `\/`) == "" {
190 return true
191 }
192 }
193 abs = filepath.Clean(raw)
194 if runtime.GOOS == "windows" {
195 vol := filepath.VolumeName(abs)
196 if vol == "" {
197 return false
198 }
199 rest := strings.TrimPrefix(abs, vol)
200 return strings.Trim(rest, `\/`) == ""
201 }
202 return abs == string(filepath.Separator)
203 }
204
205 // IsHomeDir reports whether abs is the user's home directory.
206 func IsHomeDir(abs, home string) bool {
207 abs = canonicalDir(abs)
208 home = canonicalDir(home)
209 if abs == "" || home == "" {
210 return false
211 }
212 if abs == home {
213 return true
214 }
215 if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
216 return strings.EqualFold(abs, home)
217 }
218 return false
219 }
220
221 func canonicalDir(path string) string {
222 path = strings.TrimSpace(path)
223 if path == "" {
224 return ""
225 }
226 if resolved, err := ResolveAbsPath(path); err == nil {
227 return resolved
228 }
229 return filepath.Clean(path)
230 }
231
232 // ProtectedWriteRoots returns the Reasonix state boundary that must stay
233 // read-only after a broad ancestor grant. Protecting the parent also covers
234 // state files that do not exist when the sandbox starts.
235 func ProtectedWriteRoots(stateRoot string) []string {
236 stateRoot = canonicalDir(stateRoot)
237 if stateRoot == "" {
238 return nil
239 }
240 return []string{stateRoot}
241 }
242
243 // IsProtectedWritePath reports whether abs is a Reasonix session store,
244 // runtime ledger, or security-boundary file.
245 func IsProtectedWritePath(abs, stateRoot string) bool {
246 abs = canonicalDir(abs)
247 stateRoot = canonicalDir(stateRoot)
248 if abs == "" || stateRoot == "" {
249 return false
250 }
251 if sameWritePath(abs, stateRoot) {
252 return true
253 }
254 if protectedPathWithin(filepath.Join(stateRoot, "sessions"), abs) {
255 return true
256 }
257 relRoot, relPath := stateRoot, abs
258 if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
259 relRoot, relPath = strings.ToLower(relRoot), strings.ToLower(relPath)
260 }
261 if rel, err := filepath.Rel(relRoot, relPath); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) {
262 if isProtectedStateFile(rel) {
263 return true
264 }
265 }
266 return protectedPathWithin(filepath.Join(stateRoot, "projects"), abs)
267 }
268
269 func protectedPathWithin(root, path string) bool {
270 if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
271 root, path = strings.ToLower(root), strings.ToLower(path)
272 }
273 return PathWithin(root, path)
274 }
275
276 func isProtectedStateFile(name string) bool {
277 lower := strings.ToLower(name)
278 if strings.HasPrefix(lower, "desktop-") {
279 return true
280 }
281 switch lower {
282 case "settings.json", "metrics-pending.json", "crash-pending.json":
283 return true
284 default:
285 return false
286 }
287 }
288
289 // NormalizeWriteDirs validates and collapses a list of requested write
290 // directories. broadHome is true when the request includes the user's home.
291 func NormalizeWriteDirs(raw []string, workDir, home, stateRoot string) (abs, display []string, broadHome bool, err error) {
292 seen := map[string]bool{}
293 for _, dir := range raw {
294 resolved, _, nerr := NormalizeWriteDir(dir, workDir, home)
295 if nerr != nil {
296 return nil, nil, false, nerr
297 }
298 if verr := ValidateWriteDir(resolved, stateRoot); verr != nil {
299 return nil, nil, false, verr
300 }
301 if seen[resolved] {
302 continue
303 }
304 seen[resolved] = true
305 abs = append(abs, resolved)
306 if IsHomeDir(resolved, home) {
307 broadHome = true
308 }
309 }
310 abs = CollapseWriteRoots(abs)
311 display = make([]string, 0, len(abs))
312 for _, dir := range abs {
313 display = append(display, DisplayWritePath(dir, home))
314 }
315 if abs == nil {
316 abs = []string{}
317 }
318 return abs, display, broadHome, nil
319 }
320
321 // ValidateWriteDir rejects filesystem roots and Reasonix-protected paths.
322 // The user's home directory is allowed; callers should flag it as high risk.
323 func ValidateWriteDir(abs, stateRoot string) error {
324 if IsFilesystemRoot(abs) {
325 return fmt.Errorf("write directory %q is a filesystem root and cannot be granted", abs)
326 }
327 if IsProtectedWritePath(abs, stateRoot) {
328 return fmt.Errorf("write directory %q is a Reasonix session or runtime-state path and cannot be granted", abs)
329 }
330 return nil
331 }
332
333 // EnsureWriteDir creates approved with 0o755 when it does not exist and
334 // returns the verified identity that callers must grant. It rejects a path
335 // whose symlink-resolved identity changed after the approval prompt.
336 func EnsureWriteDir(approved, stateRoot string) (string, error) {
337 approved = filepath.Clean(strings.TrimSpace(approved))
338 if approved == "" || approved == "." {
339 return "", fmt.Errorf("write directory is empty")
340 }
341 if !filepath.IsAbs(approved) {
342 return "", fmt.Errorf("write directory %q is not absolute", approved)
343 }
344 if err := ValidateWriteDir(approved, stateRoot); err != nil {
345 return "", err
346 }
347 resolved, err := ResolveAbsPath(approved)
348 if err != nil {
349 return "", fmt.Errorf("resolve approved write directory %q: %w", approved, err)
350 }
351 if !sameWritePath(approved, resolved) {
352 return "", fmt.Errorf("approved write directory %q changed identity to %q", approved, resolved)
353 }
354 if err := ValidateWriteDir(resolved, stateRoot); err != nil {
355 return "", err
356 }
357
358 info, err := os.Stat(resolved)
359 switch {
360 case err == nil:
361 if !info.IsDir() {
362 return "", fmt.Errorf("write path %q is not a directory", approved)
363 }
364 case os.IsNotExist(err):
365 if err := os.MkdirAll(approved, 0o755); err != nil {
366 return "", fmt.Errorf("create write directory %q: %w", approved, err)
367 }
368 default:
369 return "", fmt.Errorf("stat write directory %q: %w", approved, err)
370 }
371
372 resolved, err = ResolveAbsPath(approved)
373 if err != nil {
374 return "", fmt.Errorf("re-resolve write directory %q: %w", approved, err)
375 }
376 if !sameWritePath(approved, resolved) {
377 return "", fmt.Errorf("approved write directory %q changed identity to %q", approved, resolved)
378 }
379 if err := ValidateWriteDir(resolved, stateRoot); err != nil {
380 return "", err
381 }
382 info, err = os.Stat(resolved)
383 if err != nil {
384 return "", fmt.Errorf("stat created write directory %q: %w", approved, err)
385 }
386 if !info.IsDir() {
387 return "", fmt.Errorf("created write path %q is not a directory", approved)
388 }
389 return resolved, nil
390 }
391
392 func sameWritePath(left, right string) bool {
393 left = filepath.Clean(left)
394 right = filepath.Clean(right)
395 // Approval identity must remain exact: macOS and Windows can host
396 // case-sensitive paths. A casing change must re-prompt, not reuse a grant.
397 return left == right
398 }
399
399 lines GO