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 denied("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 shell arguments or redirections.
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 denied("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 if err := s.checkCommandPaths(cmd); err != nil {
139 return err
140 }
141 return nil
142}
143
144// cdTarget extracts the destination of a leading `cd <dir>` command.
145// Returns ok=false when seg is not a `cd` invocation or has no explicit
146// target (bare `cd` / `cd -` go home / previous dir; we leave those to
147// the path checks on subsequent tool calls rather than guessing).
148func cdTarget(seg string) (string, bool) {
149 seg = strings.TrimSpace(seg)
150 if seg != "cd" && !strings.HasPrefix(seg, "cd ") {
151 return "", false
152 }
153 arg := strings.TrimSpace(strings.TrimPrefix(seg, "cd"))
154 if arg == "" || arg == "-" {
155 return "", false
156 }
157 // Drop surrounding quotes if the model wrapped the path.
158 if len(arg) >= 2 {
159 if (arg[0] == '"' && arg[len(arg)-1] == '"') || (arg[0] == '\'' && arg[len(arg)-1] == '\'') {
160 arg = arg[1 : len(arg)-1]
161 }
162 }
163 return arg, true
164}
165
166func (s *Sandbox) checkCommandPaths(cmd string) error {
167 for _, tok := range shellWords(cmd) {
168 for _, p := range candidatePaths(tok) {
169 if err := s.checkCommandPath(p); err != nil {
170 return err
171 }
172 }
173 }
174 return nil
175}
176
177func shellWords(cmd string) []string {
178 var words []string
179 var b strings.Builder
180 var quote rune
181 escaped := false
182 flush := func() {
183 if b.Len() == 0 {
184 return
185 }
186 words = append(words, b.String())
187 b.Reset()
188 }
189 for _, r := range cmd {
190 if escaped {
191 b.WriteRune(r)
192 escaped = false
193 continue
194 }
195 if r == '\\' && quote != '\'' {
196 b.WriteRune(r)
197 escaped = true
198 continue
199 }
200 if quote != 0 {
201 if r == quote {
202 quote = 0
203 continue
204 }
205 b.WriteRune(r)
206 continue
207 }
208 switch r {
209 case '\'', '"':
210 quote = r
211 case ' ', '\t', '\n', '\r', ';', '&', '|', '(', ')', '<', '>':
212 flush()
213 default:
214 b.WriteRune(r)
215 }
216 }
217 flush()
218 return words
219}
220
221func candidatePaths(tok string) []string {
222 if tok == "" || strings.Contains(tok, "://") {
223 return nil
224 }
225 if strings.HasPrefix(tok, "-") {
226 if _, value, ok := strings.Cut(tok, "="); ok {
227 return candidatePaths(value)
228 }
229 return nil
230 }
231 if name, value, ok := strings.Cut(tok, "="); ok && name != "" && value != "" {
232 return candidatePaths(value)
233 }
234 if looksLikePath(tok) {
235 return []string{tok}
236 }
237 return nil
238}
239
240func looksLikePath(s string) bool {
241 return filepath.IsAbs(s) ||
242 strings.HasPrefix(s, "/") ||
243 strings.HasPrefix(s, "~") ||
244 strings.HasPrefix(s, "$HOME") ||
245 strings.HasPrefix(s, "${HOME}") ||
246 strings.HasPrefix(s, "..") ||
247 strings.Contains(s, "/") ||
248 strings.Contains(s, `\\`)
249}
250
251func (s *Sandbox) checkCommandPath(path string) error {
252 // The platform null device is safe for shell input/output redirection even
253 // though it lives outside the sandbox root. Keep this allowlist exact so
254 // other device paths and lookalike descendants still fail closed.
255 if path == os.DevNull {
256 return nil
257 }
258 rootAbs, err := canonical(s.Root)
259 if err != nil {
260 return fmt.Errorf("sandbox root: %w", err)
261 }
262 expanded := expandHome(path)
263 if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
264 return denied("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
265 }
266 if !filepath.IsAbs(expanded) {
267 expanded = filepath.Join(s.Root, filepath.FromSlash(expanded))
268 }
269 target, err := canonicalOrParent(expanded)
270 if err != nil {
271 return fmt.Errorf("sandbox path: %w", err)
272 }
273 if !isUnder(rootAbs, target) {
274 return denied("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
275 }
276 return nil
277}
278
279// checkCDTarget resolves a `cd` destination (relative to the sandbox
280// root, with ~ and $HOME expansion) and rejects it only when it lands
281// outside the root.
282func (s *Sandbox) checkCDTarget(dir string) error {
283 rootAbs, err := canonical(s.Root)
284 if err != nil {
285 return fmt.Errorf("sandbox root: %w", err)
286 }
287 expanded := expandHome(dir)
288 // A leading forward slash is an absolute POSIX path. The shell uses
289 // POSIX-style paths regardless of host OS, but on Windows
290 // filepath.IsAbs("/etc") is false and filepath.Join would fold it
291 // back inside root, so a `cd /etc` escape would slip through. Treat
292 // it as an unconditional escape attempt: outside any project root.
293 if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
294 return denied("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
295 }
296 if !filepath.IsAbs(expanded) {
297 // Relative targets (including `..`) resolve against the sandbox
298 // root, which is the bash tool's working directory when jailed.
299 expanded = filepath.Join(s.Root, expanded)
300 }
301 target, err := canonicalOrParent(expanded)
302 if err != nil {
303 return fmt.Errorf("sandbox path: %w", err)
304 }
305 if !isUnder(rootAbs, target) {
306 return denied("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
307 }
308 return nil
309}
310
311// expandHome replaces a leading ~, ~/, or $HOME with the user's home
312// directory so cd-target resolution matches what the shell would do.
313func expandHome(p string) string {
314 home, err := os.UserHomeDir()
315 if err != nil || home == "" {
316 return p
317 }
318 switch {
319 case p == "~" || p == "$HOME" || p == "${HOME}":
320 return home
321 case strings.HasPrefix(p, "~/"):
322 return filepath.Join(home, p[2:])
323 case strings.HasPrefix(p, "$HOME/"):
324 return filepath.Join(home, p[len("$HOME/"):])
325 case strings.HasPrefix(p, "${HOME}/"):
326 return filepath.Join(home, p[len("${HOME}/"):])
327 default:
328 return p
329 }
330}
331
332// canonical returns an absolute, symlink-resolved path. Errors on missing files.
333func canonical(p string) (string, error) {
334 abs, err := filepath.Abs(p)
335 if err != nil {
336 return "", err
337 }
338 return filepath.EvalSymlinks(abs)
339}
340
341// canonicalOrParent returns the canonical path for p; if p doesn't exist,
342// it walks up until it finds an existing directory, then appends the
343// remaining path components. This catches symlink-escapes in non-existent
344// subtrees (e.g. "new-file" inside a symlinked dir).
345func canonicalOrParent(p string) (string, error) {
346 abs, err := filepath.Abs(p)
347 if err != nil {
348 return "", err
349 }
350 // If the full path exists, resolve it.
351 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
352 return resolved, nil
353 }
354 // Otherwise, find the longest existing prefix.
355 remaining := ""
356 current := abs
357 for {
358 if resolved, err := filepath.EvalSymlinks(current); err == nil {
359 return filepath.Join(resolved, remaining), nil
360 }
361 parent := filepath.Dir(current)
362 if parent == current {
363 return abs, nil
364 }
365 remaining = filepath.Join(filepath.Base(current), remaining)
366 current = parent
367 }
368}
369
370// isUnder reports whether target is equal to root or a descendant of it.
371func isUnder(root, target string) bool {
372 rootSep := root
373 if !strings.HasSuffix(rootSep, string(filepath.Separator)) {
374 rootSep += string(filepath.Separator)
375 }
376 return target == root || strings.HasPrefix(target, rootSep)
377}