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 | |||
| 374 | 374 | ||
| 375 | 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 | 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 | ||
| @@ -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 { | |||
| 97 | 97 | // CheckCommand applies a lightweight sanity check to a bash command | |
| 98 | 98 | // when jailed. We cannot fully sandbox a shell, but we can reject the | |
| 99 | 99 | // 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 | 101 | func (s *Sandbox) CheckCommand(cmd string) error { | |
| 102 | 102 | if !s.Locked() { | |
| 103 | 103 | return nil | |
| @@ -135,6 +135,9 @@ func (s *Sandbox) CheckCommand(cmd string) error { | |||
| 135 | 135 | return err | |
| 136 | 136 | } | |
| 137 | 137 | } | |
| 138 | + | if err := s.checkCommandPaths(cmd); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 138 | 141 | return nil | |
| 139 | 142 | } | |
| 140 | 143 | ||
| @@ -160,6 +163,113 @@ func cdTarget(seg string) (string, bool) { | |||
| 160 | 163 | 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 | 273 | // checkCDTarget resolves a `cd` destination (relative to the sandbox | |
| 164 | 274 | // root, with ~ and $HOME expansion) and rejects it only when it lands | |
| 165 | 275 | // outside the root. | |
| @@ -200,12 +310,14 @@ func expandHome(p string) string { | |||
| 200 | 310 | return p | |
| 201 | 311 | } | |
| 202 | 312 | switch { | |
| 203 | - | case p == "~" || p == "$HOME": | |
| 313 | + | case p == "~" || p == "$HOME" || p == "${HOME}": | |
| 204 | 314 | return home | |
| 205 | 315 | case strings.HasPrefix(p, "~/"): | |
| 206 | 316 | return filepath.Join(home, p[2:]) | |
| 207 | 317 | case strings.HasPrefix(p, "$HOME/"): | |
| 208 | 318 | return filepath.Join(home, p[len("$HOME/"):]) | |
| 319 | + | case strings.HasPrefix(p, "${HOME}/"): | |
| 320 | + | return filepath.Join(home, p[len("${HOME}/"):]) | |
| 209 | 321 | default: | |
| 210 | 322 | return p | |
| 211 | 323 | } | |
| @@ -97,7 +97,7 @@ func (s *Sandbox) DisplayPath(abs, given string) string { | |||||
| 97 | // CheckCommand applies a lightweight sanity check to a bash command | 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 | 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 | 99 | // 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 nil | 103 | return nil | ||
| @@ -135,6 +135,9 @@ func (s *Sandbox) CheckCommand(cmd string) error { | |||||
| 135 | return err | 135 | return err | ||
| 136 | } | 136 | } | ||
| 137 | } | 137 | } | ||
| 138 | + | if err := s.checkCommandPaths(cmd); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 138 | return nil | 141 | return nil | ||
| 139 | } | 142 | } | ||
| 140 | 143 | ||||
| @@ -160,6 +163,113 @@ func cdTarget(seg string) (string, bool) { | |||||
| 160 | return arg, true | 163 | 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 sandbox | 273 | // checkCDTarget resolves a `cd` destination (relative to the sandbox | ||
| 164 | // root, with ~ and $HOME expansion) and rejects it only when it lands | 274 | // 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 p | 310 | return p | ||
| 201 | } | 311 | } | ||
| 202 | switch { | 312 | switch { | ||
| 203 | - | case p == "~" || p == "$HOME": | 313 | + | case p == "~" || p == "$HOME" || p == "${HOME}": |
| 204 | return home | 314 | 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 p | 322 | return p | ||
| 211 | } | 323 | } | ||
M packages/agent/tools/sandbox_test.go +33 -0
| @@ -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 | 78 | // TestSandboxAllowsCDIntoSubdir is the regression for issue #39: a `cd` | |
| 60 | 79 | // into a subdirectory of the sandbox root, spelled as an absolute path, | |
| 61 | 80 | // must be allowed. The old guard rejected any `cd /...` outright, which | |
| @@ -69,12 +88,17 @@ func TestSandboxAllowsCDIntoSubdir(t *testing.T) { | |||
| 69 | 88 | sb := NewSandbox(root) | |
| 70 | 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 | 95 | allowed := []string{ | |
| 73 | 96 | "cd " + sub + " && go build ./...", | |
| 74 | 97 | "cd " + root + " && go build ./...", | |
| 75 | 98 | "cd " + root, // bare cd to root | |
| 76 | 99 | "cd packages/provider && go build", | |
| 77 | 100 | "cd \"" + sub + "\" && ls", // quoted absolute path | |
| 101 | + | "cat \"" + insideFile + "\"", | |
| 78 | 102 | } | |
| 79 | 103 | for _, c := range allowed { | |
| 80 | 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 | 114 | blocked := []string{ | |
| 86 | 115 | "cd /etc", | |
| 87 | 116 | "cd / && ls", | |
| 88 | 117 | "cd ..", // parent of root escapes | |
| 89 | 118 | "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 | 124 | for _, c := range blocked { | |
| 92 | 125 | 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, which | 80 | // 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 root | 98 | "cd " + root, // bare cd to root | ||
| 76 | "cd packages/provider && go build", | 99 | "cd packages/provider && go build", | ||
| 77 | "cd \"" + sub + "\" && ls", // quoted absolute path | 100 | "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 escapes | 117 | "cd ..", // parent of root escapes | ||
| 89 | "cd " + filepath.Dir(root), // explicit parent | 118 | "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 { | ||