fix(sandbox): restore archived file modes when extracting a workspace tar - #4287
Conversation
… tar safe_extract_tarfile created every regular file with a hardcoded 0o600 and never applied the archived member mode, so a UnixLocalSandbox persist_workspace -> hydrate_workspace round trip stripped the executable bit from workspace scripts and reset every file to owner-only. Apply the member mode on the open descriptor after creation, using the same policy as the standard library tarfile 'data' filter that this extractor replaces: setuid, setgid, sticky and group/other write bits are dropped, execute bits survive only when the owner had them, and the owner always keeps read and write access. Directory and symlink modes are left alone, matching that filter as well.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a4cd2d509f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
seratch
left a comment
There was a problem hiding this comment.
The mode-restoration fix is needed and the sanitization policy is appropriate. Before merge, please keep the new file at its initial 0o600 mode until the payload has been copied successfully and flushed, then apply fchmod() on the still-open descriptor. As written, the final readable/executable mode becomes visible while the file may still contain partial data, and a copy failure leaves that partial file with final permissions.
Please also add a failure-path regression test that makes the payload copy fail after writing some bytes and asserts the destination remains 0o600. With that change, the PR should be ready for another review.
Apply the restored mode after shutil.copyfileobj() completes and the buffer is flushed, still on the open descriptor. A partially written file now keeps its private 0o600 creation mode, so a concurrent reader never sees partial data through the final readable/executable mode and a failed copy does not leave the partial file with those permissions.
|
Good catch — fixed in
Added That test is a real regression test for this exact ordering — with the previous commit's placement it fails with Verification re-run in full on the updated branch: |
Summary
Component:
src/agents/sandbox/util/tar_utils.py—safe_extract_tarfile(), the workspace-tar extractor used byUnixLocalSandbox.hydrate_workspace().Problem:
safe_extract_tarfilecreates every regular file with a hardcoded0o600and never applies the archived member mode. Apersist_workspace()→hydrate_workspace()round trip therefore rewrites every file in the restored workspace to owner-read/write only. Executable scripts in the workspace — a checked-in./bin/start, a.venv/bin/*wrapper,node_modules/.bin/*— come back non-executable, so shell commands that worked before the snapshot fail after the restore with a permission error.Repro (no key, no live service):
Before:
0o600,os.access(..., os.X_OK)isFalse.After:
0o755,os.access(..., os.X_OK)isTrue.Root cause:
_write_file()passes0o600as the creation mode toos.open()and stops there. That value is only meant to keep the file private while it is being written; nothing ever restoresmember.mode. The custom extractor exists to enforce a stricter containment policy thanTarFile.extractall(), and when it replaced the stdlib path it also dropped the stdlib's mode handling.Fix: apply the archived mode with
os.fchmod()on the still-open descriptor, aftershutil.copyfileobj()completes and the buffer is flushed. The file therefore keeps its private0o600creation mode for as long as it may hold partial data: a concurrent reader never sees a half-written file through its final readable/executable mode, and a copy that fails partway (interrupted input, full disk) leaves the partial file at0o600rather than at those permissions. The mode is sanitized with the same policy the standard library'starfiledatafilter applies to regular files, which is the policy this extractor replaced:mode & 0o755— setuid, setgid, sticky and group/other write bits are dropped, so an untrusted archive cannot plant a setuid binary or a world-writable file.0o100.| 0o600— the owner always keeps read and write access.Why minimal:
datafilter ("Ignore mode for directories & symlinks"), and it avoids the ordering hazard of applying a restrictive archived directory mode before that directory's children are extracted.os.fchmodis applied to the open descriptor rather than the path, so it cannot follow a path that changed afteros.open(..., O_EXCL | O_NOFOLLOW)succeeded, and no extrastat/chmodround trip on the path is introduced.hasattr(os, "fchmod"), mirroring the existinghasattr(os, "O_NOFOLLOW")guard two lines above, becauseos.fchmodis Unix-only and this suite runs in the Windows CI job.Non-goals: the zip/source-archive materialization path in
sandbox/session/archive_extraction.py, directory and symlink modes, and ownership (uid/gid) restoration — the stdlibdatafilter drops ownership too, and doing otherwise would need privileges the extractor does not have.Test plan
New tests in
tests/sandbox/test_tar_utils.py, all skipped on Windows viasys.platform == "win32"because they assert POSIX permission bits (the existing suite already runs unskipped on Windows, so the new cases had to opt out rather than the file):test_safe_extract_tarfile_restores_regular_file_modes— 11 parametrized cases pinning the sanitizing policy:0o755→0o755,0o644→0o644,0o600→0o600,0o700→0o700,0o444→0o644(owner stays writable),0o000→0o600(owner stays readable),0o777→0o755(group/other write dropped),0o655→0o644(execute without owner-execute dropped), and0o4755/0o2755/0o1755 → 0o755(setuid/setgid/sticky dropped).test_safe_extract_tarfile_keeps_workspace_scripts_executable— the real-world symptom: a nested./bin/startstays executable and a siblingREADME.mddoes not become executable.test_safe_extract_tarfile_restores_mode_when_replacing_an_existing_file— the replace path (_prepare_replaceable_leafunlinks, then re-creates), extracting0o644and then0o755over it.test_safe_extract_tarfile_keeps_a_partially_written_file_private— the failure path.tar.extractfileis patched to return a payload that yields one chunk and then raises, so the copy fails mid-write; the test asserts the partial bytes did land, that the destination is still0o600, and that it is not executable._file()in that test module gained an optionalmodeargument; every existing call site is unchanged.Red/green was verified twice, by toggling only
src/agents/sandbox/util/tar_utils.pywith the tests already in place:upstream/main: 11 failed, 37 passed → 49 passed. The three mode cases that pass in both directions (0o600, and the two clamped-to-0o600boundaries) are kept as boundary coverage.fchmodbefore the copy):test_safe_extract_tarfile_keeps_a_partially_written_file_privatefails withassert 0o755 == 0o600on the partially written file, and passes once thefchmodmoved after the copy and flush.Validation, all from a clean worktree branched at
upstream/main(237716cf), Python 3.12.13, macOS:uv run pytest tests/sandbox/test_tar_utils.py -q→ 49 passed (run 3× consecutively, identical)uv run pytest tests/sandbox -q→ 1005 passed, 2 skippedmake format→ 864 files left unchanged;ruff check --fixall checks passedmake lint→ all checks passedmake typecheck→ mypy: no issues in 851 source files; pyright: 0 errors, 0 warningsmake tests→ 7078 passed, 28 skipped; serial suite 77 passed, 11 skipped, 55 deselectedbash .agents/skills/code-change-verification/scripts/run.sh→ all commands passedgit diff --check→ cleanNot run:
make coverageandmake build-docs(no docs touched); the Windows CI job — the new tests skip there by construction and the runtime change is behindhasattr(os, "fchmod"), but the Windows behavior itself is unverified locally.Issue number
None — found by reading
safe_extract_tarfileagainst the stdlibtarfilefilter policy it replaced.Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR