v0.0.21 4dde650

sandbox.go RawHistoryFolder

1package tools
2
3import (
4	"fmt"
5	"path/filepath"
6	"strings"
7	"sync/atomic"
8)
9
10// Sandbox guards tool access to the filesystem and shell. When Locked
11// is true (1), file tools refuse paths outside Root and bash runs with
12// a restricted environment.
13//
14// The value is designed to be shared across tool instances (by pointer).
15// Enable/Disable are atomic so they can be toggled from the TUI.
16type Sandbox struct {
17	Root   string
18	locked atomic.Bool
19}
20
21// NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.
22func NewSandbox(root string) *Sandbox {
23	s := &Sandbox{Root: root}
24	return s
25}
26
27// Lock enables sandboxing.
28func (s *Sandbox) Lock() { s.locked.Store(true) }
29
30// Unlock disables sandboxing.
31func (s *Sandbox) Unlock() { s.locked.Store(false) }
32
33// Locked reports whether the sandbox is enforcing limits.
34func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() }
35
36// CheckPath verifies that path resolves inside the sandbox root.
37// Returns an error describing the violation if not. No-op when unlocked.
38// Callers should pass an already-absolute path (use resolvePath() first).
39func (s *Sandbox) CheckPath(path string) error {
40	if !s.Locked() {
41		return nil
42	}
43	rootAbs, err := canonical(s.Root)
44	if err != nil {
45		return fmt.Errorf("sandbox root: %w", err)
46	}
47	// Resolve the target to an absolute path. Walk up until we find an
48	// existing parent so symlinks inside nonexistent dirs are still caught.
49	target, err := canonicalOrParent(path)
50	if err != nil {
51		return fmt.Errorf("sandbox path: %w", err)
52	}
53	if !isUnder(rootAbs, target) {
54		return fmt.Errorf("locked: path %q is outside sandbox root %q (use /unlock to disable)", path, s.Root)
55	}
56	return nil
57}
58
59// CheckCommand applies a lightweight sanity check to a bash command
60// when locked. We cannot fully sandbox a shell, but we can reject the
61// most obvious escapes so the model does not accidentally touch files
62// outside root via absolute paths.
63func (s *Sandbox) CheckCommand(cmd string) error {
64	if !s.Locked() {
65		return nil
66	}
67	cmd = strings.TrimSpace(cmd)
68	if cmd == "" {
69		return nil
70	}
71	// Reject obvious destructive roots.
72	banned := []string{
73		"rm -rf /", "rm -rf ~", "rm -rf $HOME",
74		"sudo ", "su ",
75		"chmod -R ", "chown -R ",
76		"mkfs", "dd if=", "dd of=/",
77	}
78	lower := strings.ToLower(cmd)
79	for _, b := range banned {
80		if strings.Contains(lower, strings.ToLower(b)) {
81			return fmt.Errorf("locked: command contains banned pattern %q (use /unlock to disable)", b)
82		}
83	}
84	// Heuristic: reject a leading `cd /` or `cd ~` that tries to move
85	// the shell out of the sandbox. Note this only catches simple cases;
86	// a determined adversary can still escape. This is a speed bump for
87	// the model, not a security boundary.
88	first := strings.TrimSpace(strings.SplitN(cmd, ";", 2)[0])
89	first = strings.TrimSpace(strings.SplitN(first, "&&", 2)[0])
90	if strings.HasPrefix(first, "cd /") || strings.HasPrefix(first, "cd ~") ||
91		strings.HasPrefix(first, "cd $HOME") || strings.HasPrefix(first, "cd ..") {
92		return fmt.Errorf("locked: cd outside sandbox root is not allowed (use /unlock to disable)")
93	}
94	return nil
95}
96
97// canonical returns an absolute, symlink-resolved path. Errors on missing files.
98func canonical(p string) (string, error) {
99	abs, err := filepath.Abs(p)
100	if err != nil {
101		return "", err
102	}
103	return filepath.EvalSymlinks(abs)
104}
105
106// canonicalOrParent returns the canonical path for p; if p doesn't exist,
107// it walks up until it finds an existing directory, then appends the
108// remaining path components. This catches symlink-escapes in non-existent
109// subtrees (e.g. "new-file" inside a symlinked dir).
110func canonicalOrParent(p string) (string, error) {
111	abs, err := filepath.Abs(p)
112	if err != nil {
113		return "", err
114	}
115	// If the full path exists, resolve it.
116	if resolved, err := filepath.EvalSymlinks(abs); err == nil {
117		return resolved, nil
118	}
119	// Otherwise, find the longest existing prefix.
120	remaining := ""
121	current := abs
122	for {
123		if resolved, err := filepath.EvalSymlinks(current); err == nil {
124			return filepath.Join(resolved, remaining), nil
125		}
126		parent := filepath.Dir(current)
127		if parent == current {
128			return abs, nil
129		}
130		remaining = filepath.Join(filepath.Base(current), remaining)
131		current = parent
132	}
133}
134
135// isUnder reports whether target is equal to root or a descendant of it.
136func isUnder(root, target string) bool {
137	rootSep := root
138	if !strings.HasSuffix(rootSep, string(filepath.Separator)) {
139		rootSep += string(filepath.Separator)
140	}
141	return target == root || strings.HasPrefix(target, rootSep)
142}

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