v0.3.34 4548953

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 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 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	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 fmt.Errorf("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 fmt.Errorf("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 fmt.Errorf("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 fmt.Errorf("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}

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