detached bd355ab

Block jailed bash paths outside sandbox

commit
bd355ab8beb2dc71c0535f5d12c5b5c319d00a99
author
patriceckhart · · 2026-07-22 15:03 UTC
parent
4d92b3d
signature
unsigned
3 files · +148 -3
M README.md +1 -1
@@ -374,7 +374,7 @@ zot also auto-compacts in the background: after any turn that leaves context usa
374374
375375 ### `/jail`
376376
377-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns: `sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
377+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
378378
379379 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zot under docker or a proper sandbox.
380380
@@ -374,7 +374,7 @@ zot also auto-compacts in the background: after any turn that leaves context usa
374 374
375 ### `/jail`375 ### `/jail`
376 376
377-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns: `sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.377+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
378 378
379 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zot under docker or a proper sandbox.379 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zot under docker or a proper sandbox.
380 380
M packages/agent/tools/sandbox.go +114 -2
@@ -97,7 +97,7 @@ func (s *Sandbox) DisplayPath(abs, given string) string {
9797 // CheckCommand applies a lightweight sanity check to a bash command
9898 // when jailed. We cannot fully sandbox a shell, but we can reject the
9999 // most obvious escapes so the model does not accidentally touch files
100-// outside root via absolute paths.
100+// outside root via shell arguments or redirections.
101101 func (s *Sandbox) CheckCommand(cmd string) error {
102102 if !s.Locked() {
103103 return nil
@@ -135,6 +135,9 @@ func (s *Sandbox) CheckCommand(cmd string) error {
135135 return err
136136 }
137137 }
138+ if err := s.checkCommandPaths(cmd); err != nil {
139+ return err
140+ }
138141 return nil
139142 }
140143
@@ -160,6 +163,113 @@ func cdTarget(seg string) (string, bool) {
160163 return arg, true
161164 }
162165
166+func (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+
177+func 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+
221+func 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+
240+func 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+
251+func (s *Sandbox) checkCommandPath(path string) error {
252+ rootAbs, err := canonical(s.Root)
253+ if err != nil {
254+ return fmt.Errorf("sandbox root: %w", err)
255+ }
256+ expanded := expandHome(path)
257+ if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
258+ return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
259+ }
260+ if !filepath.IsAbs(expanded) {
261+ expanded = filepath.Join(s.Root, filepath.FromSlash(expanded))
262+ }
263+ target, err := canonicalOrParent(expanded)
264+ if err != nil {
265+ return fmt.Errorf("sandbox path: %w", err)
266+ }
267+ if !isUnder(rootAbs, target) {
268+ return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
269+ }
270+ return nil
271+}
272+
163273 // checkCDTarget resolves a `cd` destination (relative to the sandbox
164274 // root, with ~ and $HOME expansion) and rejects it only when it lands
165275 // outside the root.
@@ -200,12 +310,14 @@ func expandHome(p string) string {
200310 return p
201311 }
202312 switch {
203- case p == "~" || p == "$HOME":
313+ case p == "~" || p == "$HOME" || p == "${HOME}":
204314 return home
205315 case strings.HasPrefix(p, "~/"):
206316 return filepath.Join(home, p[2:])
207317 case strings.HasPrefix(p, "$HOME/"):
208318 return filepath.Join(home, p[len("$HOME/"):])
319+ case strings.HasPrefix(p, "${HOME}/"):
320+ return filepath.Join(home, p[len("${HOME}/"):])
209321 default:
210322 return p
211323 }
@@ -97,7 +97,7 @@ func (s *Sandbox) DisplayPath(abs, given string) string {
97 // CheckCommand applies a lightweight sanity check to a bash command97 // CheckCommand applies a lightweight sanity check to a bash command
98 // when jailed. We cannot fully sandbox a shell, but we can reject the98 // when jailed. We cannot fully sandbox a shell, but we can reject the
99 // most obvious escapes so the model does not accidentally touch files99 // most obvious escapes so the model does not accidentally touch files
100-// outside root via absolute paths.100+// outside root via shell arguments or redirections.
101 func (s *Sandbox) CheckCommand(cmd string) error {101 func (s *Sandbox) CheckCommand(cmd string) error {
102 if !s.Locked() {102 if !s.Locked() {
103 return nil103 return nil
@@ -135,6 +135,9 @@ func (s *Sandbox) CheckCommand(cmd string) error {
135 return err135 return err
136 }136 }
137 }137 }
138+ if err := s.checkCommandPaths(cmd); err != nil {
139+ return err
140+ }
138 return nil141 return nil
139 }142 }
140 143
@@ -160,6 +163,113 @@ func cdTarget(seg string) (string, bool) {
160 return arg, true163 return arg, true
161 }164 }
162 165
166+func (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+
177+func 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+
221+func 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+
240+func 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+
251+func (s *Sandbox) checkCommandPath(path string) error {
252+ rootAbs, err := canonical(s.Root)
253+ if err != nil {
254+ return fmt.Errorf("sandbox root: %w", err)
255+ }
256+ expanded := expandHome(path)
257+ if strings.HasPrefix(expanded, "/") && !filepath.IsAbs(expanded) {
258+ return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
259+ }
260+ if !filepath.IsAbs(expanded) {
261+ expanded = filepath.Join(s.Root, filepath.FromSlash(expanded))
262+ }
263+ target, err := canonicalOrParent(expanded)
264+ if err != nil {
265+ return fmt.Errorf("sandbox path: %w", err)
266+ }
267+ if !isUnder(rootAbs, target) {
268+ return fmt.Errorf("jailed: path %q is outside sandbox root %q (use /unjail to disable)", path, s.Root)
269+ }
270+ return nil
271+}
272+
163 // checkCDTarget resolves a `cd` destination (relative to the sandbox273 // checkCDTarget resolves a `cd` destination (relative to the sandbox
164 // root, with ~ and $HOME expansion) and rejects it only when it lands274 // root, with ~ and $HOME expansion) and rejects it only when it lands
165 // outside the root.275 // outside the root.
@@ -200,12 +310,14 @@ func expandHome(p string) string {
200 return p310 return p
201 }311 }
202 switch {312 switch {
203- case p == "~" || p == "$HOME":313+ case p == "~" || p == "$HOME" || p == "${HOME}":
204 return home314 return home
205 case strings.HasPrefix(p, "~/"):315 case strings.HasPrefix(p, "~/"):
206 return filepath.Join(home, p[2:])316 return filepath.Join(home, p[2:])
207 case strings.HasPrefix(p, "$HOME/"):317 case strings.HasPrefix(p, "$HOME/"):
208 return filepath.Join(home, p[len("$HOME/"):])318 return filepath.Join(home, p[len("$HOME/"):])
319+ case strings.HasPrefix(p, "${HOME}/"):
320+ return filepath.Join(home, p[len("${HOME}/"):])
209 default:321 default:
210 return p322 return p
211 }323 }
M packages/agent/tools/sandbox_test.go +33 -0
@@ -56,6 +56,25 @@ func TestSandboxCommandBanned(t *testing.T) {
5656 }
5757 }
5858
59+// TestBashToolRejectsOutsidePathWhenLocked is a regression for issue #94.
60+func TestBashToolRejectsOutsidePathWhenLocked(t *testing.T) {
61+ root := t.TempDir()
62+ outside := t.TempDir()
63+ outsideFile := filepath.Join(outside, "secret.txt")
64+ if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
65+ t.Fatal(err)
66+ }
67+
68+ sb := NewSandbox(root)
69+ sb.Lock()
70+ tool := &BashTool{CWD: root, Sandbox: sb}
71+
72+ _, err := tool.Execute(context.Background(), mustJSONRaw(t, map[string]any{"command": "cat " + outsideFile}), nil)
73+ if err == nil {
74+ t.Fatal("expected jailed bash to reject outside path")
75+ }
76+}
77+
5978 // TestSandboxAllowsCDIntoSubdir is the regression for issue #39: a `cd`
6079 // into a subdirectory of the sandbox root, spelled as an absolute path,
6180 // must be allowed. The old guard rejected any `cd /...` outright, which
@@ -69,12 +88,17 @@ func TestSandboxAllowsCDIntoSubdir(t *testing.T) {
6988 sb := NewSandbox(root)
7089 sb.Lock()
7190
91+ insideFile := filepath.Join(root, "inside file.txt")
92+ if err := os.WriteFile(insideFile, []byte("inside"), 0o644); err != nil {
93+ t.Fatal(err)
94+ }
7295 allowed := []string{
7396 "cd " + sub + " && go build ./...",
7497 "cd " + root + " && go build ./...",
7598 "cd " + root, // bare cd to root
7699 "cd packages/provider && go build",
77100 "cd \"" + sub + "\" && ls", // quoted absolute path
101+ "cat \"" + insideFile + "\"",
78102 }
79103 for _, c := range allowed {
80104 if err := sb.CheckCommand(c); err != nil {
@@ -82,11 +106,20 @@ func TestSandboxAllowsCDIntoSubdir(t *testing.T) {
82106 }
83107 }
84108
109+ outside := t.TempDir()
110+ outsideFile := filepath.Join(outside, "secret.txt")
111+ if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
85114 blocked := []string{
86115 "cd /etc",
87116 "cd / && ls",
88117 "cd ..", // parent of root escapes
89118 "cd " + filepath.Dir(root), // explicit parent
119+ "cat " + outsideFile,
120+ "cat <" + outsideFile,
121+ "grep secret ../secret.txt",
122+ "cat ${HOME}/.ssh/config",
90123 }
91124 for _, c := range blocked {
92125 if err := sb.CheckCommand(c); err == nil {
@@ -56,6 +56,25 @@ func TestSandboxCommandBanned(t *testing.T) {
56 }56 }
57 }57 }
58 58
59+// TestBashToolRejectsOutsidePathWhenLocked is a regression for issue #94.
60+func TestBashToolRejectsOutsidePathWhenLocked(t *testing.T) {
61+ root := t.TempDir()
62+ outside := t.TempDir()
63+ outsideFile := filepath.Join(outside, "secret.txt")
64+ if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
65+ t.Fatal(err)
66+ }
67+
68+ sb := NewSandbox(root)
69+ sb.Lock()
70+ tool := &BashTool{CWD: root, Sandbox: sb}
71+
72+ _, err := tool.Execute(context.Background(), mustJSONRaw(t, map[string]any{"command": "cat " + outsideFile}), nil)
73+ if err == nil {
74+ t.Fatal("expected jailed bash to reject outside path")
75+ }
76+}
77+
59 // TestSandboxAllowsCDIntoSubdir is the regression for issue #39: a `cd`78 // TestSandboxAllowsCDIntoSubdir is the regression for issue #39: a `cd`
60 // into a subdirectory of the sandbox root, spelled as an absolute path,79 // into a subdirectory of the sandbox root, spelled as an absolute path,
61 // must be allowed. The old guard rejected any `cd /...` outright, which80 // must be allowed. The old guard rejected any `cd /...` outright, which
@@ -69,12 +88,17 @@ func TestSandboxAllowsCDIntoSubdir(t *testing.T) {
69 sb := NewSandbox(root)88 sb := NewSandbox(root)
70 sb.Lock()89 sb.Lock()
71 90
91+ insideFile := filepath.Join(root, "inside file.txt")
92+ if err := os.WriteFile(insideFile, []byte("inside"), 0o644); err != nil {
93+ t.Fatal(err)
94+ }
72 allowed := []string{95 allowed := []string{
73 "cd " + sub + " && go build ./...",96 "cd " + sub + " && go build ./...",
74 "cd " + root + " && go build ./...",97 "cd " + root + " && go build ./...",
75 "cd " + root, // bare cd to root98 "cd " + root, // bare cd to root
76 "cd packages/provider && go build",99 "cd packages/provider && go build",
77 "cd \"" + sub + "\" && ls", // quoted absolute path100 "cd \"" + sub + "\" && ls", // quoted absolute path
101+ "cat \"" + insideFile + "\"",
78 }102 }
79 for _, c := range allowed {103 for _, c := range allowed {
80 if err := sb.CheckCommand(c); err != nil {104 if err := sb.CheckCommand(c); err != nil {
@@ -82,11 +106,20 @@ func TestSandboxAllowsCDIntoSubdir(t *testing.T) {
82 }106 }
83 }107 }
84 108
109+ outside := t.TempDir()
110+ outsideFile := filepath.Join(outside, "secret.txt")
111+ if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
85 blocked := []string{114 blocked := []string{
86 "cd /etc",115 "cd /etc",
87 "cd / && ls",116 "cd / && ls",
88 "cd ..", // parent of root escapes117 "cd ..", // parent of root escapes
89 "cd " + filepath.Dir(root), // explicit parent118 "cd " + filepath.Dir(root), // explicit parent
119+ "cat " + outsideFile,
120+ "cat <" + outsideFile,
121+ "grep secret ../secret.txt",
122+ "cat ${HOME}/.ssh/config",
90 }123 }
91 for _, c := range blocked {124 for _, c := range blocked {
92 if err := sb.CheckCommand(c); err == nil {125 if err := sb.CheckCommand(c); err == nil {

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