Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ Per-repo mode does not use the layer stack — it runs the same phases inline in
│ ▼ │
│ ┌──────────────────┐ │
│ │ Extract output │ SafeDownload() with sanitization: │
│ │ │ - Remove symlinks (sandbox escape) │
│ │ │ - Remove dangerous symlinks (sandbox escape) │
│ │ │ - Remove .git/hooks/ (hook injection) │
│ └──────┬───────────┘ │
│ ▼ │
Expand Down Expand Up @@ -316,15 +316,15 @@ SandboxClaudeConfig = "/tmp/claude-config"
| `ExecStreamReader()` | `openshell sandbox exec ...` | Streaming stdout reader |
| `Upload()` | `openshell sandbox upload ...` | Copy files into sandbox |
| `Download()` | `openshell sandbox download ...` | Copy files out of sandbox |
| `SafeDownload()` | Download + sanitize | Remove symlinks, .git/hooks |
| `SafeDownload()` | Download + sanitize | Remove dangerous symlinks (absolute or repo-escaping), .git/hooks |
| `CollectLogs()` | Download logs dir | Extract sandbox logs |
| `ExtractTranscripts()` | Download transcripts | Extract conversation transcripts |
| `Delete()` | `openshell sandbox delete` | Destroy container |

### Security: sanitizeDownload()

After downloading files from the sandbox, `sanitizeDownload()` removes:
- **Symlinks** — Prevents sandbox escape via symlink-to-host-path attacks
- **Dangerous symlinks** (absolute targets or targets that escape the repo) — Prevents sandbox escape via symlink-to-host-path attacks; relative in-repo symlinks are kept
- **.git/hooks/** — Prevents hook injection that would execute on the host

---
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,8 +554,8 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str
}
}

// 9d. Extract target repo back to host. SafeDownload removes symlinks
// and .git/hooks/ after download to prevent sandbox escape.
// 9d. Extract target repo back to host. SafeDownload removes dangerous
// symlinks (absolute or repo-escaping) and .git/hooks/ to prevent sandbox escape.
if clearErr := os.RemoveAll(repoSrc); clearErr != nil {
return fmt.Errorf("clearing local repo %s before extraction: %w", repoSrc, clearErr)
}
Expand Down
33 changes: 30 additions & 3 deletions internal/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,39 @@ const (
)

func sanitizeDownload(localDir string) error {
return filepath.WalkDir(localDir, func(path string, d fs.DirEntry, err error) error {
absLocal, err := filepath.Abs(localDir)
if err != nil {
return err
}
absLocal = filepath.Clean(absLocal)

return filepath.WalkDir(absLocal, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&fs.ModeSymlink != 0 {
return os.Remove(path)
target, readErr := os.Readlink(path)
if readErr != nil {
return os.Remove(path)
}
// Absolute targets always point outside the repo root.
if filepath.IsAbs(target) {
return os.Remove(path)
}
// Use EvalSymlinks, not filepath.Clean: Clean is textual and misses
// chains where an in-repo dir-symlink is used as a component
// (e.g. "sub/link/../../etc/passwd" cleans to inside the repo but
// follows the link to outside). Fall back to remove on error
// (dangling or looping).
rawPath := filepath.Dir(path) + string(filepath.Separator) + target
resolved, evalErr := filepath.EvalSymlinks(rawPath)
if evalErr != nil {
return os.Remove(path)
}
if !strings.HasPrefix(resolved+string(filepath.Separator), absLocal+string(filepath.Separator)) {
return os.Remove(path)
}
return nil
}

if d.IsDir() && d.Name() == "hooks" && filepath.Base(filepath.Dir(path)) == ".git" {
Expand Down Expand Up @@ -343,7 +370,7 @@ func DownloadFile(sandboxName, remotePath, localPath string) error {
}

// SafeDownload copies a directory from a sandbox to the local machine and then
// sanitizes the result by removing symlinks and .git/hooks/.
// sanitizes the result by removing dangerous symlinks (absolute or repo-escaping) and .git/hooks/.
func SafeDownload(sandboxName, remoteDir, localDir string) error {
if err := Download(sandboxName, remoteDir, localDir); err != nil {
return err
Expand Down
60 changes: 53 additions & 7 deletions internal/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,25 +149,71 @@ func TestOsRootContainment(t *testing.T) {
assert.Error(t, err)
}

func TestSanitizeDownload_RemovesSymlinks(t *testing.T) {
func TestSanitizeDownload_RemovesAbsoluteSymlinks(t *testing.T) {
dir := t.TempDir()

// Create a regular file.
require.NoError(t, os.WriteFile(filepath.Join(dir, "real.txt"), []byte("ok"), 0o644))

// Create a symlink (dangling is fine — we just need it to exist).
require.NoError(t, os.Symlink("/nonexistent/target", filepath.Join(dir, "danger")))

err := sanitizeDownload(dir)
require.NoError(t, err)

// Regular file should survive.
_, err = os.Stat(filepath.Join(dir, "real.txt"))
assert.NoError(t, err)

// Symlink should be removed.
_, err = os.Lstat(filepath.Join(dir, "danger"))
assert.True(t, os.IsNotExist(err), "symlink should have been removed")
assert.True(t, os.IsNotExist(err), "absolute symlink should have been removed")
}

func TestSanitizeDownload_KeepsRelativeSymlinksInsideRepo(t *testing.T) {
dir := t.TempDir()

require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "target.txt"), []byte("ok"), 0o644))
// Relative symlink: sub/link -> ../target.txt (stays inside dir)
require.NoError(t, os.Symlink("../target.txt", filepath.Join(dir, "sub", "link")))

err := sanitizeDownload(dir)
require.NoError(t, err)

_, err = os.Lstat(filepath.Join(dir, "sub", "link"))
assert.NoError(t, err, "relative in-repo symlink should be preserved")
}

func TestSanitizeDownload_RemovesSymlinkChainEscape(t *testing.T) {
dir := t.TempDir()

require.NoError(t, os.MkdirAll(filepath.Join(dir, "real"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
// dirlink -> ../real: relative, inside repo — sanitizeDownload keeps it.
require.NoError(t, os.Symlink("../real", filepath.Join(dir, "sub", "dirlink")))
// escape -> "sub/dirlink/../../etc/passwd":
// filepath.Clean sees: dir/sub/dirlink/../../etc/passwd → dir/etc/passwd (inside, passes textual check)
// EvalSymlinks follows: sub/dirlink → dir/real → ../../etc/passwd → outside dir (escapes)
require.NoError(t, os.Symlink("sub/dirlink/../../etc/passwd", filepath.Join(dir, "escape")))

err := sanitizeDownload(dir)
require.NoError(t, err)

_, err = os.Lstat(filepath.Join(dir, "sub", "dirlink"))
assert.NoError(t, err, "in-repo dirlink should be preserved")

_, err = os.Lstat(filepath.Join(dir, "escape"))
assert.True(t, os.IsNotExist(err), "symlink-chain escape should be removed")
}

func TestSanitizeDownload_RemovesRelativeSymlinksEscapingRepo(t *testing.T) {
dir := t.TempDir()

require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755))
// Relative symlink that traverses above dir root.
require.NoError(t, os.Symlink("../../etc/passwd", filepath.Join(dir, "sub", "escape")))

err := sanitizeDownload(dir)
require.NoError(t, err)

_, err = os.Lstat(filepath.Join(dir, "sub", "escape"))
assert.True(t, os.IsNotExist(err), "escaping relative symlink should have been removed")
}

func TestSanitizeDownload_RemovesGitHooks(t *testing.T) {
Expand Down
Loading