1package tools
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "sync/atomic"
8)
9
10// Sandbox guards tool access to the filesystem and shell. When Locked
11// is true (1), file tools refuse paths outside Root and bash runs with
12// a restricted environment.
13//
14// The value is designed to be shared across tool instances (by pointer).
15// Enable/Disable are atomic so they can be toggled from the TUI.
16type Sandbox struct {
17 Root string
18 locked atomic.Bool
19}
20
21// NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.
22func NewSandbox(root string) *Sandbox {
23 s := &Sandbox{Root: root}
24 return s
25}
26
27// Lock enables sandboxing.
28func (s *Sandbox) Lock() { s.locked.Store(true) }
29
30// Unlock disables sandboxing.
31func (s *Sandbox) Unlock() { s.locked.Store(false) }
32
33// Locked reports whether the sandbox is enforcing limits.
34func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() }
35
36// CheckPath verifies that path resolves inside the sandbox root.
37// Returns an error describing the violation if not. No-op when unlocked.
38// Callers should pass an already-absolute path (use resolvePath() first).
39func (s *Sandbox) CheckPath(path string) error {
40 if !s.Locked() {
41 return nil
42 }
43 rootAbs, err := canonical(s.Root)
44 if err != nil {
45 return fmt.Errorf("sandbox root: %w", err)
46 }
47 // Resolve the target to an absolute path. Walk up until we find an
48 // existing parent so symlinks inside nonexistent dirs are still caught.
49 target, err := canonicalOrParent(path)
50 if err != nil {
51 return fmt.Errorf("sandbox path: %w", err)
52 }
53 if !isUnder(rootAbs, target) {
54 return fmt.Errorf("locked: path %q is outside sandbox root %q (use /unlock to disable)", path, s.Root)
55 }
56 return nil
57}
58
59// CheckCommand applies a lightweight sanity check to a bash command
60// when locked. We cannot fully sandbox a shell, but we can reject the
61// most obvious escapes so the model does not accidentally touch files
62// outside root via absolute paths.
63func (s *Sandbox) CheckCommand(cmd string) error {
64 if !s.Locked() {
65 return nil
66 }
67 cmd = strings.TrimSpace(cmd)
68 if cmd == "" {
69 return nil
70 }
71 // Reject obvious destructive roots.
72 banned := []string{
73 "rm -rf /", "rm -rf ~", "rm -rf $HOME",
74 "sudo ", "su ",
75 "chmod -R ", "chown -R ",
76 "mkfs", "dd if=", "dd of=/",
77 }
78 lower := strings.ToLower(cmd)
79 for _, b := range banned {
80 if strings.Contains(lower, strings.ToLower(b)) {
81 return fmt.Errorf("locked: command contains banned pattern %q (use /unlock to disable)", b)
82 }
83 }
84 // Heuristic: reject a leading `cd /` or `cd ~` that tries to move
85 // the shell out of the sandbox. Note this only catches simple cases;
86 // a determined adversary can still escape. This is a speed bump for
87 // the model, not a security boundary.
88 first := strings.TrimSpace(strings.SplitN(cmd, ";", 2)[0])
89 first = strings.TrimSpace(strings.SplitN(first, "&&", 2)[0])
90 if strings.HasPrefix(first, "cd /") || strings.HasPrefix(first, "cd ~") ||
91 strings.HasPrefix(first, "cd $HOME") || strings.HasPrefix(first, "cd ..") {
92 return fmt.Errorf("locked: cd outside sandbox root is not allowed (use /unlock to disable)")
93 }
94 return nil
95}
96
97// canonical returns an absolute, symlink-resolved path. Errors on missing files.
98func canonical(p string) (string, error) {
99 abs, err := filepath.Abs(p)
100 if err != nil {
101 return "", err
102 }
103 return filepath.EvalSymlinks(abs)
104}
105
106// canonicalOrParent returns the canonical path for p; if p doesn't exist,
107// it walks up until it finds an existing directory, then appends the
108// remaining path components. This catches symlink-escapes in non-existent
109// subtrees (e.g. "new-file" inside a symlinked dir).
110func canonicalOrParent(p string) (string, error) {
111 abs, err := filepath.Abs(p)
112 if err != nil {
113 return "", err
114 }
115 // If the full path exists, resolve it.
116 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
117 return resolved, nil
118 }
119 // Otherwise, find the longest existing prefix.
120 remaining := ""
121 current := abs
122 for {
123 if resolved, err := filepath.EvalSymlinks(current); err == nil {
124 return filepath.Join(resolved, remaining), nil
125 }
126 parent := filepath.Dir(current)
127 if parent == current {
128 return abs, nil
129 }
130 remaining = filepath.Join(filepath.Base(current), remaining)
131 current = parent
132 }
133}
134
135// isUnder reports whether target is equal to root or a descendant of it.
136func isUnder(root, target string) bool {
137 rootSep := root
138 if !strings.HasSuffix(rootSep, string(filepath.Separator)) {
139 rootSep += string(filepath.Separator)
140 }
141 return target == root || strings.HasPrefix(target, rootSep)
142}