1package tools
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync/atomic"
9)
10
11// Sandbox guards tool access to the filesystem and shell. When Locked
12// is true (1), file tools refuse paths outside Root and bash runs with
13// a restricted environment.
14//
15// The value is designed to be shared across tool instances (by pointer).
16// Enable/Disable are atomic so they can be toggled from the TUI.
17type Sandbox struct {
18 Root string
19 Permissions *PermissionSet
20 locked atomic.Bool
21}
22
23// NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.
24func NewSandbox(root string) *Sandbox {
25 s := &Sandbox{Root: root}
26 return s
27}
28
29// Lock enables sandboxing.
30func (s *Sandbox) Lock() { s.locked.Store(true) }
31
32// Unlock disables sandboxing.
33func (s *Sandbox) Unlock() { s.locked.Store(false) }
34
35// Locked reports whether the sandbox is enforcing limits.
36func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() }
37
38// CheckPath verifies that path resolves inside the sandbox root.
39// Returns an error describing the violation if not. No-op when unlocked.
40// Callers should pass an already-absolute path (use resolvePath() first).
41func (s *Sandbox) CheckPath(path string) error {
42 if !s.Locked() {
43 return nil
44 }
45 rootAbs, err := canonical(s.Root)
46 if err != nil {
47 return fmt.Errorf("sandbox root: %w", err)
48 }
49 // Resolve the target to an absolute path. Walk up until we find an
50 // existing parent so symlinks inside nonexistent dirs are still caught.
51 target, err := canonicalOrParent(path)
52 if err != nil {
53 return fmt.Errorf("sandbox path: %w", err)
54 }
55 if !isUnder(rootAbs, target) {
56 return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
57 }
58 return nil
59}
60
61// DisplayPath returns the path the model should see in tool results
62// and error messages. When jailed, an absolute path inside the
63// sandbox root is rewritten relative to that root ("./pkg/foo.go"),
64// which keeps absolute paths out of the context window so the model
65// is nudged toward relative paths instead of trying to escape the
66// jail (see issue #39). Paths outside root, unjailed sessions, and
67// already-relative inputs are returned unchanged.
68//
69// abs should be the resolved absolute path (resolvePath output);
70// given is the path exactly as the model supplied it, used as the
71// fallback when no better relative form is available.
72func (s *Sandbox) DisplayPath(abs, given string) string {
73 if !s.Locked() {
74 return given
75 }
76 rootAbs, err := canonical(s.Root)
77 if err != nil {
78 return given
79 }
80 target, err := canonicalOrParent(abs)
81 if err != nil {
82 return given
83 }
84 if !isUnder(rootAbs, target) {
85 return given
86 }
87 rel, err := filepath.Rel(rootAbs, target)
88 if err != nil || rel == "" || strings.HasPrefix(rel, "..") {
89 return given
90 }
91 if rel == "." {
92 return "."
93 }
94 return "./" + filepath.ToSlash(rel)
95}
96
97// CheckCommand applies a lightweight sanity check to a bash command
98// when jailed. We cannot fully sandbox a shell, but we can reject the
99// most obvious escapes so the model does not accidentally touch files
100// outside root via absolute paths.
101func (s *Sandbox) CheckCommand(cmd string) error {
102 if !s.Locked() {
103 return nil
104 }
105 cmd = strings.TrimSpace(cmd)
106 if cmd == "" {
107 return nil
108 }
109 // Reject obvious destructive roots.
110 banned := []string{
111 "rm -rf /", "rm -rf ~", "rm -rf $HOME",
112 "sudo ", "su ",
113 "chmod -R ", "chown -R ",
114 "mkfs", "dd if=", "dd of=/",
115 }
116 lower := strings.ToLower(cmd)
117 for _, b := range banned {
118 if strings.Contains(lower, strings.ToLower(b)) {
119 return fmt.Errorf("jailed: command contains banned pattern %q (use /unjail to disable)", b)
120 }
121 }
122 // Heuristic: reject a leading `cd` that tries to move the shell out
123 // of the sandbox. We only reject when the target actually resolves
124 // outside root; a `cd` into a subdirectory of root (even spelled as
125 // an absolute path) is allowed, because the model frequently does
126 // `cd /abs/path/inside/root && build` and blanket-rejecting that
127 // wastes turns and nudges the model toward trying to break out.
128 // Note this only catches simple cases; a determined adversary can
129 // still escape. This is a speed bump for the model, not a security
130 // boundary.
131 first := strings.TrimSpace(strings.SplitN(cmd, ";", 2)[0])
132 first = strings.TrimSpace(strings.SplitN(first, "&&", 2)[0])
133 if target, ok := cdTarget(first); ok {
134 if err := s.checkCDTarget(target); err != nil {
135 return err
136 }
137 }
138 return nil
139}
140
141// cdTarget extracts the destination of a leading `cd <dir>` command.
142// Returns ok=false when seg is not a `cd` invocation or has no explicit
143// target (bare `cd` / `cd -` go home / previous dir; we leave those to
144// the path checks on subsequent tool calls rather than guessing).
145func cdTarget(seg string) (string, bool) {
146 seg = strings.TrimSpace(seg)
147 if seg != "cd" && !strings.HasPrefix(seg, "cd ") {
148 return "", false
149 }
150 arg := strings.TrimSpace(strings.TrimPrefix(seg, "cd"))
151 if arg == "" || arg == "-" {
152 return "", false
153 }
154 // Drop surrounding quotes if the model wrapped the path.
155 if len(arg) >= 2 {
156 if (arg[0] == '"' && arg[len(arg)-1] == '"') || (arg[0] == '\'' && arg[len(arg)-1] == '\'') {
157 arg = arg[1 : len(arg)-1]
158 }
159 }
160 return arg, true
161}
162
163// checkCDTarget resolves a `cd` destination (relative to the sandbox
164// root, with ~ and $HOME expansion) and rejects it only when it lands
165// outside the root.
166func (s *Sandbox) checkCDTarget(dir string) error {
167 rootAbs, err := canonical(s.Root)
168 if err != nil {
169 return fmt.Errorf("sandbox root: %w", err)
170 }
171 expanded := expandHome(dir)
172 // A leading forward slash is an absolute POSIX path. The shell uses
173 // POSIX-style paths regardless of host OS, but on Windows
174 // filepath.IsAbs("/etc") is false and filepath.Join would fold it
175 // back inside root, so a `cd /etc` escape would slip through. Treat
176 // it as an unconditional escape attempt: outside any project root.
177 if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
178 return fmt.Errorf("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
179 }
180 if !filepath.IsAbs(expanded) {
181 // Relative targets (including `..`) resolve against the sandbox
182 // root, which is the bash tool's working directory when jailed.
183 expanded = filepath.Join(s.Root, expanded)
184 }
185 target, err := canonicalOrParent(expanded)
186 if err != nil {
187 return fmt.Errorf("sandbox path: %w", err)
188 }
189 if !isUnder(rootAbs, target) {
190 return fmt.Errorf("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
191 }
192 return nil
193}
194
195// expandHome replaces a leading ~, ~/, or $HOME with the user's home
196// directory so cd-target resolution matches what the shell would do.
197func expandHome(p string) string {
198 home, err := os.UserHomeDir()
199 if err != nil || home == "" {
200 return p
201 }
202 switch {
203 case p == "~" || p == "$HOME":
204 return home
205 case strings.HasPrefix(p, "~/"):
206 return filepath.Join(home, p[2:])
207 case strings.HasPrefix(p, "$HOME/"):
208 return filepath.Join(home, p[len("$HOME/"):])
209 default:
210 return p
211 }
212}
213
214// canonical returns an absolute, symlink-resolved path. Errors on missing files.
215func canonical(p string) (string, error) {
216 abs, err := filepath.Abs(p)
217 if err != nil {
218 return "", err
219 }
220 return filepath.EvalSymlinks(abs)
221}
222
223// canonicalOrParent returns the canonical path for p; if p doesn't exist,
224// it walks up until it finds an existing directory, then appends the
225// remaining path components. This catches symlink-escapes in non-existent
226// subtrees (e.g. "new-file" inside a symlinked dir).
227func canonicalOrParent(p string) (string, error) {
228 abs, err := filepath.Abs(p)
229 if err != nil {
230 return "", err
231 }
232 // If the full path exists, resolve it.
233 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
234 return resolved, nil
235 }
236 // Otherwise, find the longest existing prefix.
237 remaining := ""
238 current := abs
239 for {
240 if resolved, err := filepath.EvalSymlinks(current); err == nil {
241 return filepath.Join(resolved, remaining), nil
242 }
243 parent := filepath.Dir(current)
244 if parent == current {
245 return abs, nil
246 }
247 remaining = filepath.Join(filepath.Base(current), remaining)
248 current = parent
249 }
250}
251
252// isUnder reports whether target is equal to root or a descendant of it.
253func isUnder(root, target string) bool {
254 rootSep := root
255 if !strings.HasSuffix(rootSep, string(filepath.Separator)) {
256 rootSep += string(filepath.Separator)
257 }
258 return target == root || strings.HasPrefix(target, rootSep)
259}