Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 additions & 1 deletion pkg/picod/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ func (s *Server) ExecuteHandler(c *gin.Context) {
// Use the first element as the command and the rest as arguments
cmd := exec.CommandContext(ctx, req.Command[0], req.Command[1:]...) //nolint:gosec // This is an agent designed to execute arbitrary commands

// Set working directory
// Default working directory to workspace; override if the request specifies one.
cmd.Dir = s.workspaceDir
if req.WorkingDir != "" {
safeWorkingDir, err := s.sanitizePath(req.WorkingDir)
if err != nil {
Expand All @@ -102,6 +103,15 @@ func (s *Server) ExecuteHandler(c *gin.Context) {
})
return
}
if _, statErr := os.Stat(safeWorkingDir); os.IsNotExist(statErr) {
if err := s.mkdirSafe(safeWorkingDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to create working directory: %v", err),
"code": http.StatusInternalServerError,
})
return
}
}
cmd.Dir = safeWorkingDir
Comment thread
sicaario marked this conversation as resolved.
}

Expand Down
71 changes: 70 additions & 1 deletion pkg/picod/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -219,10 +220,15 @@ func TestExecuteHandler_WorkingDirectory(t *testing.T) {
errorContains: "Invalid working directory",
},
{
name: "valid subdirectory",
name: "valid subdirectory (already exists)",
workingDir: "subdir",
expectError: false,
},
{
name: "non-existent subdirectory is auto-created",
workingDir: "newdir/nested",
expectError: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -250,6 +256,69 @@ func TestExecuteHandler_WorkingDirectory(t *testing.T) {
}
}

func TestExecuteHandler_WorkingDirectory_SymlinkEscape(t *testing.T) {
server, tmpDir := setupExecuteTestServer(t)
defer os.RemoveAll(tmpDir)
defer os.Unsetenv(PublicKeyEnvVar)

// Plant a symlink inside the workspace that points outside it.
outsideDir := t.TempDir()
symlinkPath := filepath.Join(tmpDir, "escape")
require.NoError(t, os.Symlink(outsideDir, symlinkPath))

// Attempt to execute in a subdirectory of the symlink — mkdirSafe should reject this.
req := ExecuteRequest{
Command: []string{"pwd"},
WorkingDir: "escape/newdir",
}
body, _ := json.Marshal(req)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("POST", "/api/execute", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

server.ExecuteHandler(c)

assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to create working directory")
}

func TestExecuteHandler_DefaultsToWorkspace(t *testing.T) {
server, tmpDir := setupExecuteTestServer(t)
defer os.RemoveAll(tmpDir)
defer os.Unsetenv(PublicKeyEnvVar)

// No WorkingDir set — command should run inside the workspace directory.
req := ExecuteRequest{
Command: []string{"pwd"},
}
body, _ := json.Marshal(req)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("POST", "/api/execute", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

server.ExecuteHandler(c)

assert.Equal(t, http.StatusOK, w.Code)

var resp ExecuteResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, 0, resp.ExitCode)

// Resolve symlinks on both sides so the comparison is stable on macOS
// where /tmp and /var are symlinks to /private/tmp and /private/var.
pwdOutput := strings.TrimSpace(resp.Stdout)
resolvedPwd, err := filepath.EvalSymlinks(pwdOutput)
require.NoError(t, err)
resolvedWorkspace, err := filepath.EvalSymlinks(server.workspaceDir)
require.NoError(t, err)
assert.Equal(t, resolvedWorkspace, resolvedPwd, "command should run inside the workspace directory")
}

func TestExecuteHandler_ExitCodes(t *testing.T) {
server, tmpDir := setupExecuteTestServer(t)
defer os.RemoveAll(tmpDir)
Expand Down
55 changes: 48 additions & 7 deletions pkg/picod/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,17 +386,58 @@ func parseFileMode(modeStr string) os.FileMode {
return os.FileMode(mode)
}

// setWorkspace sets the global workspace directory
func (s *Server) setWorkspace(dir string) {
// mkdirSafe creates dir and all parents within the workspace, guarding against
// symlink-based directory traversal in existing path components.
//
// os.MkdirAll follows symlinks in the existing portion of the path. If an
// attacker has placed a symlink inside the workspace that points outside (e.g.
// workspace/link -> /), MkdirAll("workspace/link/newdir") would silently create
// /newdir. mkdirSafe walks up to the deepest existing ancestor, resolves its
// symlinks, and verifies it is still inside the workspace before proceeding.
func (s *Server) mkdirSafe(dir string) error {
resolvedWorkspace, err := filepath.EvalSymlinks(s.workspaceDir)
if err != nil {
resolvedWorkspace = filepath.Clean(s.workspaceDir)
}

// Walk up to the deepest ancestor that already exists.
existing := dir
for {
if _, err := os.Lstat(existing); err == nil {
resolved, err := filepath.EvalSymlinks(existing)
if err != nil {
return fmt.Errorf("failed to resolve path %q: %w", existing, err)
}
rel, relErr := filepath.Rel(resolvedWorkspace, resolved)
if relErr != nil || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." {
return fmt.Errorf("access denied: path %q escapes workspace jail via symlink", dir)
}
break
}
parent := filepath.Dir(existing)
if parent == existing {
// Reached filesystem root — workspace itself will be created by MkdirAll.
break
}
existing = parent
}
return os.MkdirAll(dir, 0755)
}

// setWorkspace sets the workspace directory and creates it if it does not exist.
func (s *Server) setWorkspace(dir string) error {
klog.Infof("setWorkspace called with dir: %q", dir)
absDir, err := filepath.Abs(dir)
if err != nil {
klog.Warningf("Failed to resolve absolute path for workspace '%s': %v", dir, err)
s.workspaceDir = dir // Fallback to provided path
} else {
s.workspaceDir = absDir
klog.Infof("Resolved workspace to absolute path: %q", s.workspaceDir)
return fmt.Errorf("failed to resolve absolute path for workspace %q: %w", dir, err)
}
Comment on lines 428 to 433
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setWorkspace now returns an error, but if filepath.Abs(dir) fails it only logs a warning and falls back to using the provided (potentially relative) path. This can lead to creating the workspace relative to the process CWD and can break the workspace-jail assumptions in sanitizePath/mkdirSafe. Consider returning the Abs error instead of continuing with a fallback path.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a problem
This should return the Abs error instead of continuing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@acsoto fixed

s.workspaceDir = absDir
klog.Infof("Resolved workspace to absolute path: %q", s.workspaceDir)
if err := os.MkdirAll(s.workspaceDir, 0755); err != nil {
return fmt.Errorf("failed to create workspace directory %q: %w", s.workspaceDir, err)
}
klog.Infof("Workspace directory ensured: %q", s.workspaceDir)
return nil
Comment thread
sicaario marked this conversation as resolved.
}

// sanitizePath ensures path is within allowed scope, preventing directory traversal attacks
Expand Down
6 changes: 4 additions & 2 deletions pkg/picod/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ func TestSetWorkspace(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &Server{}
server.setWorkspace(tt.dir)
err := server.setWorkspace(tt.dir)
assert.NoError(t, err)

if tt.checkAbs {
// Verify workspace is set to absolute path
Expand All @@ -269,7 +270,8 @@ func TestSetWorkspace_WithTemporaryDirectory(t *testing.T) {
defer os.RemoveAll(tmpDir)

server := &Server{}
server.setWorkspace(tmpDir)
err = server.setWorkspace(tmpDir)
assert.NoError(t, err)

assert.Equal(t, tmpDir, server.workspaceDir)
}
9 changes: 6 additions & 3 deletions pkg/picod/picod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,8 @@ func TestPicoD_SetWorkspace(t *testing.T) {
// Case 1: Absolute Path
absPath, err := filepath.Abs(realDir)
require.NoError(t, err)
server.setWorkspace(realDir)
err = server.setWorkspace(realDir)
require.NoError(t, err)
assert.Equal(t, resolve(absPath), resolve(server.workspaceDir))

// Case 2: Relative Path
Expand All @@ -407,12 +408,14 @@ func TestPicoD_SetWorkspace(t *testing.T) {
require.NoError(t, err)
defer func() { require.NoError(t, os.Chdir(originalWd)) }()

server.setWorkspace("real")
err = server.setWorkspace("real")
require.NoError(t, err)
assert.Equal(t, resolve(absPath), resolve(server.workspaceDir))

// Case 3: Symlink
absLinkPath, err := filepath.Abs(linkDir)
require.NoError(t, err)
server.setWorkspace(linkDir)
err = server.setWorkspace(linkDir)
require.NoError(t, err)
assert.Equal(t, resolve(absLinkPath), resolve(server.workspaceDir))
}
13 changes: 6 additions & 7 deletions pkg/picod/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,16 @@ func NewServer(config Config) *Server {

// Initialize workspace directory
klog.Infof("Initializing workspace with config.Workspace: %q", config.Workspace)
if config.Workspace != "" {
s.setWorkspace(config.Workspace)
klog.Infof("Set workspace to configured value: %q", config.Workspace)
} else {
// Default to current working directory if not specified
workspaceDir := config.Workspace
if workspaceDir == "" {
cwd, err := os.Getwd()
if err != nil {
klog.Fatalf("Failed to get current working directory: %v", err)
}
s.setWorkspace(cwd)
klog.Infof("Set workspace to current working directory: %q", cwd)
workspaceDir = cwd
}
if err := s.setWorkspace(workspaceDir); err != nil {
klog.Fatalf("Failed to initialize workspace: %v", err)
}
klog.Infof("Final workspace directory: %q", s.workspaceDir)

Expand Down
18 changes: 18 additions & 0 deletions pkg/picod/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,24 @@ func TestNewServer_WorkspaceConfiguration(t *testing.T) {
assert.Equal(t, absCwd, server.workspaceDir)
},
},
{
name: "non-existent workspace directory is created",
setupWorkDir: func(t *testing.T) (string, func()) {
tmpDir, err := os.MkdirTemp("", "picod-server-test-*")
require.NoError(t, err)
// Point to a subdirectory that does not exist yet
nonExistent := filepath.Join(tmpDir, "workspace", "nested")
return nonExistent, func() { os.RemoveAll(tmpDir) }
},
verifyResult: func(t *testing.T, server *Server) {
assert.NotNil(t, server)
assert.True(t, filepath.IsAbs(server.workspaceDir))
// Directory must have been created by setWorkspace
info, err := os.Stat(server.workspaceDir)
assert.NoError(t, err, "workspace directory should exist after NewServer")
assert.True(t, info.IsDir())
},
},
{
name: "with relative path workspace",
setupWorkDir: func(t *testing.T) (string, func()) {
Expand Down
Loading