-
Notifications
You must be signed in to change notification settings - Fork 0
fix(#605): retry sandbox cleanup with chmod on permission denied #620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,10 @@ package cli | |
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "io/fs" | ||
| "net/http" | ||
| "os" | ||
| "os/exec" | ||
|
|
@@ -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). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
|
|
@@ -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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
There was a problem hiding this comment.
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.