Skip to content
Closed
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
29 changes: 27 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -887,8 +889,12 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep

// 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(hostRepositoryDir); clearErr != nil {
return fmt.Errorf("clearing local repo %s before extraction: %w", hostRepositoryDir, clearErr)
// Use forceRemoveAll to handle files created by sandboxed processes with
// restrictive permissions (#605). Warn on failure instead of aborting so
// the post-script still runs (review results are more valuable than a
// clean temp directory).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[high] stale-file-contamination

SafeDownload overlays onto the target directory without clearing it. When forceRemoveAll fails and the call site continues with a warning, files from the prior iteration that the sandbox deleted persist as ghost artifacts, potentially corrupting validation results in the iteration loop.

Suggested fix: Extract into a fresh temporary directory and atomically rename into place. Alternatively, only downgrade to warning on permission errors (errors.Is(clearErr, fs.ErrPermission)) and keep the hard abort for other error types, restricting warn-and-continue to the final iteration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] error-handling

The call site downgrades ALL forceRemoveAll errors to warnings, not just permission errors. forceRemoveAll correctly distinguishes permission errors from other error types, but the caller discards that distinction. Non-permission errors (I/O errors, EBUSY, filesystem corruption) should still hard-abort as the original code intended.

Suggested fix: Add an errors.Is(clearErr, fs.ErrPermission) check at the call site: warn-and-continue for permission errors, hard-abort for all other error types.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] scope-mismatch

The PR title says retry on permission denied but the call-site change converts all cleanup failures to warnings, not just permission-denied. This broader behavior change should be explicitly acknowledged.

if clearErr := forceRemoveAll(hostRepositoryDir); clearErr != nil {
printer.StepWarn(fmt.Sprintf("Could not fully clear %s before extraction (continuing): %v", hostRepositoryDir, clearErr))
}
repoExtractStart := time.Now()
printer.StepStart("Extracting target repo")
Expand Down Expand Up @@ -1296,6 +1302,25 @@ func escapeForDoubleQuotes(s string) string {
return s
}

// forceRemoveAll removes a directory tree. If the initial removal fails with a
// permission error (e.g. files created by a sandboxed process with a different
// UID), it makes all entries owner-writable and retries. See #605.
func forceRemoveAll(path string) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] incorrect-documentation

The doc comment and issue describe the failure as different-UID files, but os.Chmod on different-UID files requires CAP_FOWNER and will fail. The function fixes same-UID restrictive directory permissions. The doc comment should accurately describe the scope.

err := os.RemoveAll(path)
if err == nil || !errors.Is(err, fs.ErrPermission) {
return err
}
// Best-effort chmod: make every entry owner-rwx so the retry can unlink.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] symlink-following

os.Chmod follows symlinks. While prior sanitizeDownload strips dangerous symlinks, adding a symlink guard (d.Type() and fs.ModeSymlink != 0) in the WalkDir callback is a one-line defensive improvement.

_ = filepath.WalkDir(path, func(p string, _ fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // keep walking
}
_ = os.Chmod(p, 0o700)
return nil
})
return os.RemoveAll(path)
}

// validationFailMessage returns a human-readable message for a validation
// script failure. When the script produces output, that output is used;
// otherwise it falls back to the exec error string (e.g. ENOENT / EACCES).
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,43 @@ func TestEffectiveMaxRuntimeFetches_MatchesFetchsvcDefault(t *testing.T) {
}
}

func TestForceRemoveAll(t *testing.T) {
t.Run("removes normal directory", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
require.NoError(t, os.MkdirAll(filepath.Join(target, "sub"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(target, "sub", "file"), []byte("x"), 0o644))

require.NoError(t, forceRemoveAll(target))
_, err := os.Stat(target)
assert.True(t, os.IsNotExist(err))
})

t.Run("handles restrictive directory permissions", func(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] test-coverage

The test exercises same-UID restrictive directory permissions but does not document that the different-UID scenario is a known limitation that cannot be tested without root.

if os.Getuid() == 0 {
t.Skip("test requires non-root to simulate permission errors")
}
dir := t.TempDir()
target := filepath.Join(dir, "target")
sub := filepath.Join(target, "sub")
require.NoError(t, os.MkdirAll(sub, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(sub, "file"), []byte("x"), 0o644))
// Remove write from the subdirectory so os.RemoveAll fails.
require.NoError(t, os.Chmod(sub, 0o500))

// Confirm plain RemoveAll fails.
require.Error(t, os.RemoveAll(target))

require.NoError(t, forceRemoveAll(target))
_, err := os.Stat(target)
assert.True(t, os.IsNotExist(err))
})

t.Run("nonexistent path is no-op", func(t *testing.T) {
require.NoError(t, forceRemoveAll(filepath.Join(t.TempDir(), "nope")))
})
}

type mockForgeClient struct {
forge.Client
}
Loading