v0.2.53 c7f7c01

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

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 476

↑↓ moveenter openesc close