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) (err error) {
42 defer markPolicyError(&err)
43 if !s.Locked() {
44 return nil
45 }
46 rootAbs, err := canonical(s.Root)
47 if err != nil {
48 return fmt.Errorf("sandbox root: %w", err)
49 }
50 // Resolve the target to an absolute path. Walk up until we find an
51 // existing parent so symlinks inside nonexistent dirs are still caught.
52 target, err := canonicalOrParent(path)
53 if err != nil {
54 return fmt.Errorf("sandbox path: %w", err)
55 }
56 if !isUnder(rootAbs, target) {
57 return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
58 }
59 return nil
60}
61
62// DisplayPath returns the path the model should see in tool results
63// and error messages. When jailed, an absolute path inside the
64// sandbox root is rewritten relative to that root ("./pkg/foo.go"),
65// which keeps absolute paths out of the context window so the model
66// is nudged toward relative paths instead of trying to escape the
67// jail (see issue #39). Paths outside root, unjailed sessions, and
68// already-relative inputs are returned unchanged.
69//
70// abs should be the resolved absolute path (resolvePath output);
71// given is the path exactly as the model supplied it, used as the
72// fallback when no better relative form is available.
73func (s *Sandbox) DisplayPath(abs, given string) string {
74 if !s.Locked() {
75 return given
76 }
77 rootAbs, err := canonical(s.Root)
78 if err != nil {
79 return given
80 }
81 target, err := canonicalOrParent(abs)
82 if err != nil {
83 return given
84 }
85 if !isUnder(rootAbs, target) {
86 return given
87 }
88 rel, err := filepath.Rel(rootAbs, target)
89 if err != nil || rel == "" || strings.HasPrefix(rel, "..") {
90 return given
91 }
92 if rel == "." {
93 return "."
94 }
95 return "./" + filepath.ToSlash(rel)
96}
97
98// CheckCommand applies a lightweight sanity check to a bash command
99// when jailed. We cannot fully sandbox a shell, but we can reject the
100// most obvious escapes so the model does not accidentally touch files
101// outside root via shell arguments or redirections.
102func (s *Sandbox) CheckCommand(cmd string) (err error) {
103 defer markPolicyError(&err)
104 if !s.Locked() {
105 return nil
106 }
107 cmd = strings.TrimSpace(cmd)
108 if cmd == "" {
109 return nil
110 }
111 // Reject obvious destructive roots.
112 banned := []string{
113 "rm -rf /", "rm -rf ~", "rm -rf $HOME",
114 "sudo ", "su ",
115 "chmod -R ", "chown -R ",
116 "mkfs", "dd if=", "dd of=/",
117 }
118 lower := strings.ToLower(cmd)
119 for _, b := range banned {
120 if strings.Contains(lower, strings.ToLower(b)) {
121 return fmt.Errorf("jailed: command contains banned pattern %q (use /unjail to disable)", b)
122 }
123 }
124 // Heuristic: reject a leading `cd` that tries to move the shell out
125 // of the sandbox. We only reject when the target actually resolves
126 // outside root; a `cd` into a subdirectory of root (even spelled as
127 // an absolute path) is allowed, because the model frequently does
128 // `cd /abs/path/inside/root && build` and blanket-rejecting that
129 // wastes turns and nudges the model toward trying to break out.
130 // Note this only catches simple cases; a determined adversary can
131 // still escape. This is a speed bump for the model, not a security
132 // boundary.
133 first := strings.TrimSpace(strings.SplitN(cmd, ";", 2)[0])
134 first = strings.TrimSpace(strings.SplitN(first, "&&", 2)[0])
135 if target, ok := cdTarget(first); ok {
136 if err := s.checkCDTarget(target); err != nil {
137 return err
138 }
139 }
140 if err := s.checkCommandPaths(cmd); err != nil {
141 return err
142 }
143 return nil
144}
145
146// cdTarget extracts the destination of a leading `cd <dir>` command.
147// Returns ok=false when seg is not a `cd` invocation or has no explicit
148// target (bare `cd` / `cd -` go home / previous dir; we leave those to
149// the path checks on subsequent tool calls rather than guessing).
150func cdTarget(seg string) (string, bool) {
151 seg = strings.TrimSpace(seg)
152 if seg != "cd" && !strings.HasPrefix(seg, "cd ") {
153 return "", false
154 }
155 arg := strings.TrimSpace(strings.TrimPrefix(seg, "cd"))
156 if arg == "" || arg == "-" {
157 return "", false
158 }
159 // Drop surrounding quotes if the model wrapped the path.
160 if len(arg) >= 2 {
161 if (arg[0] == '"' && arg[len(arg)-1] == '"') || (arg[0] == '\'' && arg[len(arg)-1] == '\'') {
162 arg = arg[1 : len(arg)-1]
163 }
164 }
165 return arg, true
166}
167
168func (s *Sandbox) checkCommandPaths(cmd string) error {
169 for _, tok := range shellWords(cmd) {
170 for _, p := range candidatePaths(tok) {
171 if err := s.checkCommandPath(p); err != nil {
172 return err
173 }
174 }
175 }
176 return nil
177}
178
179func shellWords(cmd string) []string {
180 var words []string
181 var b strings.Builder
182 var quote rune
183 escaped := false
184 flush := func() {
185 if b.Len() == 0 {
186 return
187 }
188 words = append(words, b.String())
189 b.Reset()
190 }
191 for _, r := range cmd {
192 if escaped {
193 b.WriteRune(r)
194 escaped = false
195 continue
196 }
197 if r == '\\' && quote != '\'' {
198 b.WriteRune(r)
199 escaped = true
200 continue
201 }
202 if quote != 0 {
203 if r == quote {
204 quote = 0
205 continue
206 }
207 b.WriteRune(r)
208 continue
209 }
210 switch r {
211 case '\'', '"':
212 quote = r
213 case ' ', '\t', '\n', '\r', ';', '&', '|', '(', ')', '<', '>':
214 flush()
215 default:
216 b.WriteRune(r)
217 }
218 }
219 flush()
220 return words
221}
222
223func candidatePaths(tok string) []string {
224 if tok == "" || strings.Contains(tok, "://") {
225 return nil
226 }
227 if strings.HasPrefix(tok, "-") {
228 if _, value, ok := strings.Cut(tok, "="); ok {
229 return candidatePaths(value)
230 }
231 return nil
232 }
233 if name, value, ok := strings.Cut(tok, "="); ok && name != "" && value != "" {
234 return candidatePaths(value)
235 }
236 if looksLikePath(tok) {
237 return []string{tok}
238 }
239 return nil
240}
241
242func looksLikePath(s string) bool {
243 return filepath.IsAbs(s) ||
244 strings.HasPrefix(s, "/") ||
245 strings.HasPrefix(s, "~") ||
246 strings.HasPrefix(s, "$HOME") ||
247 strings.HasPrefix(s, "${HOME}") ||
248 strings.HasPrefix(s, "..") ||
249 strings.Contains(s, "/") ||
250 strings.Contains(s, `\\`)
251}
252
253func (s *Sandbox) checkCommandPath(path string) error {
254 // The platform null device is safe for shell input/output redirection even
255 // though it lives outside the sandbox root. Keep this allowlist exact so
256 // other device paths and lookalike descendants still fail closed.
257 if path == os.DevNull {
258 return nil
259 }
260 rootAbs, err := canonical(s.Root)
261 if err != nil {
262 return fmt.Errorf("sandbox root: %w", err)
263 }
264 expanded := expandHome(path)
265 if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
266 return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
267 }
268 if !filepath.IsAbs(expanded) {
269 expanded = filepath.Join(s.Root, filepath.FromSlash(expanded))
270 }
271 target, err := canonicalOrParent(expanded)
272 if err != nil {
273 return fmt.Errorf("sandbox path: %w", err)
274 }
275 if !isUnder(rootAbs, target) {
276 return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
277 }
278 return nil
279}
280
281// checkCDTarget resolves a `cd` destination (relative to the sandbox
282// root, with ~ and $HOME expansion) and rejects it only when it lands
283// outside the root.
284func (s *Sandbox) checkCDTarget(dir string) error {
285 rootAbs, err := canonical(s.Root)
286 if err != nil {
287 return fmt.Errorf("sandbox root: %w", err)
288 }
289 expanded := expandHome(dir)
290 // A leading forward slash is an absolute POSIX path. The shell uses
291 // POSIX-style paths regardless of host OS, but on Windows
292 // filepath.IsAbs("/etc") is false and filepath.Join would fold it
293 // back inside root, so a `cd /etc` escape would slip through. Treat
294 // it as an unconditional escape attempt: outside any project root.
295 if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
296 return fmt.Errorf("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
297 }
298 if !filepath.IsAbs(expanded) {
299 // Relative targets (including `..`) resolve against the sandbox
300 // root, which is the bash tool's working directory when jailed.
301 expanded = filepath.Join(s.Root, expanded)
302 }
303 target, err := canonicalOrParent(expanded)
304 if err != nil {
305 return fmt.Errorf("sandbox path: %w", err)
306 }
307 if !isUnder(rootAbs, target) {
308 return fmt.Errorf("jailed: cd outside sandbox root is not allowed (use /unjail to disable)")
309 }
310 return nil
311}
312
313// expandHome replaces a leading ~, ~/, or $HOME with the user's home
314// directory so cd-target resolution matches what the shell would do.
315func expandHome(p string) string {
316 home, err := os.UserHomeDir()
317 if err != nil || home == "" {
318 return p
319 }
320 switch {
321 case p == "~" || p == "$HOME" || p == "${HOME}":
322 return home
323 case strings.HasPrefix(p, "~/"):
324 return filepath.Join(home, p[2:])
325 case strings.HasPrefix(p, "$HOME/"):
326 return filepath.Join(home, p[len("$HOME/"):])
327 case strings.HasPrefix(p, "${HOME}/"):
328 return filepath.Join(home, p[len("${HOME}/"):])
329 default:
330 return p
331 }
332}
333
334// canonical returns an absolute, symlink-resolved path. Errors on missing files.
335func canonical(p string) (string, error) {
336 abs, err := filepath.Abs(p)
337 if err != nil {
338 return "", err
339 }
340 return filepath.EvalSymlinks(abs)
341}
342
343// canonicalOrParent returns the canonical path for p; if p doesn't exist,
344// it walks up until it finds an existing directory, then appends the
345// remaining path components. This catches symlink-escapes in non-existent
346// subtrees (e.g. "new-file" inside a symlinked dir).
347func canonicalOrParent(p string) (string, error) {
348 abs, err := filepath.Abs(p)
349 if err != nil {
350 return "", err
351 }
352 // If the full path exists, resolve it.
353 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
354 return resolved, nil
355 }
356 // Otherwise, find the longest existing prefix.
357 remaining := ""
358 current := abs
359 for {
360 if resolved, err := filepath.EvalSymlinks(current); err == nil {
361 return filepath.Join(resolved, remaining), nil
362 }
363 parent := filepath.Dir(current)
364 if parent == current {
365 return abs, nil
366 }
367 remaining = filepath.Join(filepath.Base(current), remaining)
368 current = parent
369 }
370}
371
372// isUnder reports whether target is equal to root or a descendant of it.
373func isUnder(root, target string) bool {
374 rootSep := root
375 if !strings.HasSuffix(rootSep, string(filepath.Separator)) {
376 rootSep += string(filepath.Separator)
377 }
378 return target == root || strings.HasPrefix(target, rootSep)
379}