v0.3.82 2e387ff

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) (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}

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