v0.2.82 444a7ea

sandbox.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close