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