Skip to content

fix(sandbox): reject apply_patch create_file on an existing file - #4893

Open
ayaangazali wants to merge 14 commits into
openai:mainfrom
ayaangazali:fix/apply-patch-create-existing
Open

fix(sandbox): reject apply_patch create_file on an existing file#4893
ayaangazali wants to merge 14 commits into
openai:mainfrom
ayaangazali:fix/apply-patch-create-existing

Conversation

@ayaangazali

Copy link
Copy Markdown
Contributor

Summary

*** Add File: is described to the model as creating a new file:

*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).

The other two operations enforce the precondition that sentence implies. delete_file calls _ensure_exists, and update_file fails through _read_text with ApplyPatchFileNotFoundError. create_file enforced nothing. It went straight to _write_text, which writes unconditionally.

So an Add File aimed at a path that already exists silently destroys that file. The grammar cannot prevent it, since filename is /(.+)/ and no CFG can express a filesystem precondition, and a model re-creating a file it wrote in an earlier turn is an ordinary thing to see.

What made me want to fix it rather than leave it is the reported output. Driving a real patch through the tool boundary on current main:

session.files[Path("/workspace/notes.txt")] = b"important existing user content\n"

tool = SandboxApplyPatchTool(session=session)
await tool._on_invoke_tool(ctx, """*** Begin Patch
*** Add File: notes.txt
+clobbered
*** End Patch""")
TOOL OUTPUT TO MODEL: 'Created notes.txt'
FILE CONTENT NOW:     b'clobbered'

The model is told a new file was created. Nothing raises, nothing warns, and the previous contents are unrecoverable, so neither the model nor the caller has any signal that would prompt recovery.

The fix checks that the destination is absent before writing, mirroring the _ensure_exists precondition already used by delete_file. It reuses ApplyPatchDiffError, which is the general apply_patch validation error in this module already (it covers unknown operation types and unsupported patch formats, not only diff content), so no new error class, ErrorCode, or exported symbol is added. The message names the operation that does work on an existing file:

apply_patch cannot create notes.txt because it already exists. Use an update_file operation to change an existing file.

This is independent of #4890, which fixes the case-only rename in the update_file branch. Different operation, no textual overlap.

One existing test changed. test_editor_runs_file_operations_as_bound_user asserts that every file operation runs as the bound user by recording the user of each call. The new absence probe is a third read, and it correctly runs as the bound user, so the expected list goes from two entries to three. The property under test is unchanged.

No docs/ change: the model-facing description already says Add File creates a new file, and this makes the runtime agree with it.

Test plan

tests/sandbox/test_apply_patch.py::test_apply_patch_create_rejects_an_existing_file asserts both halves of the outcome: the operation raises, and the original bytes are still there afterwards. Reverting only src/agents/sandbox/apply_patch.py and rerunning gives Failed: DID NOT RAISE <class 'agents.sandbox.errors.ApplyPatchDiffError'>, so it fails on the unfixed code.

.agents/skills/code-change-verification/scripts/run.sh passes: format clean, lint clean, typecheck clean, tests pass. tests/sandbox/ is 1448 passed, 2 skipped.

Issue number

None. Found while reading the apply_patch precondition handling.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

I should be upfront that I work on these with heavy AI assistance. I read the code, reproduced the behavior and checked the reasoning myself before sending it, but if I have misread the intent here I would rather you close it than spend review time on it.

Copilot AI lite review requested due to automatic review settings September 6, 2026 19:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f36d84ba6b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/apply_patch.py Outdated
Comment thread src/agents/sandbox/apply_patch.py Outdated

@seratch seratch left a comment

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.

The data-loss case is worth fixing, and the expected-error tracing change is correct. The remaining problem is that the absence check and write are separate operations. A concurrent sandbox command can create the file after the read, and the unconditional write then destroys that content. Reading also does not establish that a dangling symlink entry is absent. Please enforce create-if-absent at the backend write boundary, preserving the bound user and path policy, and cover an intervening creator whose contents must survive. Existing files should continue to use update_file.

@ayaangazali

Copy link
Copy Markdown
Contributor Author

The two tests-windows failures were mine, and they are fixed in d68c465.

Root cause: the span assertion test I added needs FilesystemTestSandboxSession, which is typed to UnixLocalSandboxSessionState, and importing agents.sandbox.sandboxes.unix_local raises ImportError on Windows by design. tests/conftest.py already collect-ignores the other files that depend on it, including sandbox/test_session_sinks.py where I took the harness from, but sandbox/test_apply_patch.py should stay collectible on Windows because its other 25 tests are platform independent. So the narrow fix is a skipif on that one test, matching what test_posix_tool_paths.py and test_run_cwd.py already do with the same reason string.

The unix-only imports stay inside the test body on purpose. At module scope the skipif would not help, because collection itself would fail to import unix_local and take the whole file down on Windows.

Nothing in src/ changed for this, so the behaviour under review is the same as the commit you may have already looked at.

One thing I cannot confirm from my side: the workflow run for d68c465 is sitting at action_required, so it is waiting on maintainer approval rather than reporting. I am not going to push again to try to trigger it. Locally .agents/skills/code-change-verification/scripts/run.sh is clean and tests/sandbox/test_apply_patch.py is 25 passed.

@ayaangazali
ayaangazali force-pushed the fix/apply-patch-create-existing branch from d68c465 to 6ecc682 Compare September 7, 2026 16:23
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Done in 6ecc682. You were right that the probe was not good enough, and the dangling symlink half turned out to be sharper than I gave it credit for.

Where the enforcement moved. New BaseSandboxSession.write_new_file() claims the target name before the payload is written and raises FileExistsError when the name is already taken. The shared implementation runs a shell noclobber redirection, so set -C makes the redirect itself the O_EXCL attempt. UnixLocalSandboxSession overrides it with os.open(O_CREAT | O_EXCL) for its direct path and delegates to the shared version when a user is bound. apply_patch create_file calls it and no longer probes, which also removes the tracing workaround from the earlier commit since there is no probe read left to mark as expected.

Why a method rather than a flag on write(). All nine backends override write(). A new keyword there would need each one updated to honour it, and any backend that was not, including third-party ones, would silently accept exclusive=True and keep clobbering. With a base method that has a working shared implementation, a backend that does nothing inherits correct behaviour, and a backend with a cheaper native primitive can override. If you would rather have the flag, say so and I will convert it.

The symlink case was worse than a missing check. UnixLocalSandboxSession.normalize_path resolves symlinks, so validating the whole path turned a dangling link.txt into its absent target and the write landed on the target. My first attempt still had this, and the test caught it:

normalize_path(link.txt) -> /.../missing.txt
missing.txt exists now: True
missing.txt content:    b'clobbered'

Both paths now validate the parent through the normal policy, so grants, symlinked parents and the bound user are unchanged, and leave the final component unresolved. O_EXCL fails with EEXIST on a name held by a symlink, including a dangling one, so the link is rejected instead of followed.

Coverage. In tests/sandbox/test_unix_local.py: an intervening creator's content survives and the create is rejected, a dangling symlink is rejected without its target being created, parents are created for a nested path, the bound-user path maps exit 13 to FileExistsError without writing, and the bound-user path dispatches the unresolved link name rather than the resolved target. In tests/sandbox/test_apply_patch.py, a session that reports every path as missing on read() while still holding the file is still rejected, which pins the check to the write boundary rather than a probe. Six of the seven fail without the change.

I checked the shell recipe directly rather than assuming it: sh, bash and dash all return exit 13 for both an existing file and a dangling symlink, and 0 with parents created for a fresh nested path, with existing content untouched in the rejection cases.

Existing files still have to go through update_file. The branch is rebased onto current main and force pushed, so the earlier commits are the same work replayed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ecc68255e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

All three are fixed in 72ede53, and two of them were real.

The wrapper bypass was the important one. SandboxSession did not forward write_new_file, so a session built by SandboxClient fell back to the shared implementation and never reached the UnixLocal os.open override. It forwards now.

The split write is gone. Instead of creating an empty file and then writing the payload, the payload goes to a staging name first and ln claims the target. ln fails when the name is already taken, so the content is complete before the name exists, and a failed or cancelled upload leaves only the staging entry, which is removed in a finally that cannot replace the outcome of the create.

The dash point was correct, and I had actually mis-tested it. I validated the recipe with cat > "$target" || exit 13, which returns 13 on dash because cat is a regular command, and then shipped : > "$target", which is a special builtin. Measured on this machine:

':' redirect    sh rc=13   bash rc=13   dash rc=2
'cat' redirect  sh rc=13   bash rc=13   dash rc=13

So a collision on dash surfaced as WorkspaceArchiveWriteError and WorkspaceEditor could not convert it into the actionable ApplyPatchDiffError. My unit tests could not catch this because they stub _exec_internal and never run a shell. There is now a test that runs the shipped script constant through sh, dash and bash and asserts 13 for both an existing file and a dangling symlink and 0 with the full payload for a fresh nested path. I confirmed it fails if the : version is restored.

The umask point is right too. os.open(..., 0o644) narrowed the mode compared with Path.open("wb"), which creates 0o666 & ~umask. Under a collaborative umask that would have made Add File produce 0644 where the ordinary write path produces 0664. It passes 0o666 now and lets the umask decide.

Full stack is clean locally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72ede535aa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/apply_patch.py Outdated
Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Fixed in 3356606. The leaf-resolution one was the real defect and it was live through the actual tool path, not just theoretical.

Preserving the leaf name. WorkspaceEditor.apply_operation normalizes destination before dispatching, and UnixLocalSandboxSession.normalize_path resolves leaf symlinks, so my primitive was handed the link target. Reproduced through session.apply_patch() on main plus my previous commit:

RESULT: 'Done!'
missing.txt created: True
dir listing: ['link.txt', 'missing.txt']

The create path now passes the unresolved path. My earlier symlink test called write_new_file directly, which is exactly why it passed while the real path was broken, so the new tests drive session.apply_patch() instead. Reverting only the apply_patch.py change makes the dangling-symlink test fail again.

The directory case. Bare ln treats an existing directory as a target directory and links the staging file inside it, so the create reported success and left a hidden payload. The script now rejects a name held by a directory before linking. Locally this was already correct, but the shared path serves the remote backends, so it mattered there.

Local staging. The Unix-local override claimed the name with os.open and then copied, so a write that failed partway left an empty file holding the name and a retry got FileExistsError. It stages the payload and then os.links it into place, matching the shared path. os.link fails with EEXIST for a file, a directory or a dangling symlink, which also let me drop the O_NOFOLLOW and ELOOP special-casing.

Staging cleanup and parent ownership. The staging write moved inside the cleanup scope so a failed or cancelled upload cannot leak .name.create-<uuid> into the workspace or a later snapshot. The parent is now created as the bound user before staging, so a fresh nested path is owned the way the previous _write_text path owned it.

The : comment appears to be a repost of the earlier finding; that redirection is gone, and ln is a regular command. The parametrized test that runs the shipped script through sh, dash and bash now also covers the existing-directory case.

Full stack clean locally. I am conscious this diff has grown well past where it started. If you would rather have the primitive landed on its own, or scoped to UnixLocal only with the remote backends following separately, say the word and I will split it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3356606ee8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/sandboxes/unix_local.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Security Review

Here are some automated security review suggestions for this pull request.

Reviewed commit: 3356606ee8

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Two of these were live and are fixed in 4ebc93c. Three are reposts of findings already addressed in 3356606. One is a blocker I do not think I should decide alone.

Fixed: the login shell. I had used sh -lc. This path runs for a filesystem-only capability set, so sourcing shell startup files that live in the workspace being edited is the wrong default for a primitive whose whole job is a single guarded create. It is sh -c now.

Fixed: collision classification. A parent that is a regular file makes mkdir raise FileExistsError, and my broad handler reported that as a collision on the requested name, so the model would be told to use update_file for a target that does not exist. Only the os.link call can report a collision now, and parent or staging failures are wrapped as write errors. Covered by a test through session.apply_patch().

Already addressed in the previous commit: the : redirection is gone, the script rejects a name held by a directory before linking (if [ -e "$target" ] || [ -L "$target" ]; then exit 13; fi, covered for sh, dash and bash including the directory case), and the parent is created with await self.mkdir(parent_path, parents=True, user=user) before staging.

The blocker: hard links. The object-storage mount point is correct and it breaks the shared implementation rather than needing another condition. A writable rclone, Mountpoint or Blobfuse destination does not provide POSIX hard links, so ln fails, cleanup removes the staging file, and Add File on a fresh mounted path returns a write error where the previous direct write() succeeded. That is a regression of working behaviour, not an edge case.

I do not think I can fix that generically. exec() has no stdin, so the shared implementation cannot stream a payload through the shell in one step. That leaves two shapes, and both are already ruled out: create empty then write, which you and Codex correctly rejected because a concurrent writer is overwritten and a failed upload leaves an empty file holding the name; or link a staged file, which needs hard links. A generic exclusive create that works on every supported mount really does need a per-backend primitive.

So the honest position is that the shared shell implementation should go, and enforcement should be backend-native. UnixLocal has one that works. The other seven backends need their own, and I cannot write or meaningfully test those.

How would you like to proceed?

  1. Scope this PR to UnixLocal: keep the native os.link primitive and the apply_patch wiring, drop the shared shell implementation, and let each remaining backend adopt the primitive in its own change. The data loss you called worth fixing is fixed where it can be verified, and nothing regresses.
  2. Close this and take the design in-house, the way you did with fix(sandbox): terminate UnixLocal execs on cancellation #4723. The reproduction, the caller-path tests and the reasoning above are all here to reuse.

I am happy either way, and I would rather close it than keep adding conditions to a shape that cannot hold. Four review rounds is past the point where I should be choosing the scope myself.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ebc93ca86

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated

Copy link
Copy Markdown

Rumbo follow-up: PR #4930 hardens this implementation for two current P2 findings — fixed-length staging basenames and early observable-target collision classification, while retaining the atomic claim for races. #4930 is intentionally based on this PR rather than presenting the work as unrelated.

@ayaangazali
ayaangazali force-pushed the fix/apply-patch-create-existing branch from 4ebc93c to 9820c3f Compare September 8, 2026 16:53
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Thanks for flagging #4930, and for basing it on this branch openly instead of presenting the work as unrelated.

On the two findings:

Fixed-length staging basename is real, and it is my regression. The staging name is .{name}.create-{uuid4().hex}, which adds 41 characters, so a destination that is valid under a 255 byte component limit can push the staging name past it. Reproduced on my current head: a 254 character filename that a plain write accepts fails create_file with WorkspaceArchiveWriteError, and the same name worked before this PR because the old path wrote straight to the destination. Agreed, and it needs fixing.

Early collision classification I read as an improvement rather than a correctness bug. Today the payload is staged before the collision is detected, so Add File onto an existing name uploads bytes that are then discarded, which is wasteful on remote backends. That seems right to do, as long as the preflight stays additive and the atomic claim is still what decides races, which is what your description says.

One practical note: #4930 shows as closed, opened 11:49:38Z and closed 11:54:17Z, so as things stand neither fix has a home. I would rather not absorb your work. If you reopen it or push a replacement, I will leave both to you and keep this branch as the base. If you would rather not carry it, say so and I will fold the staging-name fix in here with credit to you.

For whoever picks this up: there is an open question further up this thread about whether the shared shell implementation should exist at all, because hard links are not available on writable object-storage mounts. Both of these findings apply to that shared path, so the answer to that one probably decides where they land.

The branch is rebased onto current main and the full local stack is clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9820c3fa1e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

@fscfede-beep I said a few hours ago that I would leave both fixes to you, and I have gone back on half of that. Explaining rather than doing it quietly.

I fixed the staging basename in 8049c24. The reasoning: #4930 is closed, so there is nothing for the fix to stack on, and this is a regression I introduced in this branch rather than a general improvement, so leaving it sitting in an open PR while waiting was the wrong call. The staging basename is now constant at 52 characters instead of being derived from the destination. There is a regression test using a 254 character name that first confirms the platform accepts that name, so it fails for the right reason, and it fails without the fix with the same WorkspaceArchiveWriteError you predicted.

Finding credit is yours and it is in the commit message.

Your second point, preflighting an observable target before staging payload bytes, I have deliberately not touched. That one is an improvement rather than a fix to something I broke, and it is yours to land if you reopen. If you would rather I carried it too, say so and I will.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8049c24122

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Status after working every open review thread.

Fixed and resolved

  • sh -lc to sh -c, so the exclusive create no longer sources workspace-writable shell startup files (4ebc93c).
  • Fixed-length staging basename, so a valid long destination no longer fails to stage (8049c24). This was a regression I introduced, not a pre-existing limit.
  • A visible collision is now classified before any payload is staged, in both the shared path and the Unix-local override (bd47f64). A target inside a 0555 parent used to surface WorkspaceArchiveWriteError; it now surfaces ApplyPatchDiffError pointing at update_file. The atomic claim still decides real races, and a creator that wins between the probe and the link still loses the name with its staging entry cleaned up.

Answered and left open on purpose

Two threads describe the shared hard-link implementation failing on supported configurations rather than missing a condition: run_as against a backend that uploads as the provider identity cannot hard-link across owners under fs.protected_hardlinks=1, and writable rclone, Mountpoint and Blobfuse mounts have no POSIX hard links at all. Because exec() has no stdin, the shared path cannot stream a payload and claim a name in one step, and the alternative of create-empty-then-write was already rejected here.

@seratch the behaviour you asked for is implemented and covered: the name is claimed at the backend write boundary, an intervening creator keeps its content, and a dangling symlink at the target name is rejected rather than followed. What I cannot settle myself is scope. Either this lands scoped to the UnixLocal primitive, which is the one I can actually verify, with each remaining backend adopting its own later, or the shared shell path comes out and the design goes to you. I would rather stop hardening a path that may be removed. Happy with either, including closing this in favour of a maintainer-owned version.

Branch is rebased on current main, zero commits behind. Local stack clean; every affected test file passes. Checks show none because the workflow run is at action_required, the approval gate, so there is nothing red to chase.

@ayaangazali

Copy link
Copy Markdown
Contributor Author

#4931 landed and I think it answers the scope question I asked above, so flagging it rather than quietly rebasing.

That change gives UnixLocal a dir_fd plus O_NOFOLLOW file-ops layer, and routes the bound-user path through a Python worker running that same module (_USER_FILE_WORKER_SOURCE = inspect.getsource(_unix_local_file_ops)) instead of a shell. The no-user path goes through self._files.write(...). Both ends now have a symlink-safe primitive that operates on a directory descriptor and a leaf name.

That is the primitive this PR was missing, and it is better than what I built. Exclusive create becomes an O_EXCL variant of the existing open in that module, where today it is O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW. One flag, atomic in the syscall, identical on the no-user and bound-user paths.

Doing it that way removes the machinery the open threads are about, rather than hardening it:

  • no ln, so cross-owner hard links under run_as with fs.protected_hardlinks=1 stop mattering, and so do writable object-storage mounts with no hard links at all;
  • no staging entry, so there is nothing for a concurrent tool to replace and nothing to clean up;
  • no shell for the local path at all, so the startup-file concern does not arise;
  • my parent-normalize plus unresolved-leaf workaround goes away, because dir_fd plus O_NOFOLLOW is the real version of what I was approximating.

My branch still merges cleanly onto current main, but merging cleanly is not the same as being the right shape, and keeping a second mechanism next to the new one is the duplicate-lifecycle problem you raised when closing #4892.

So my proposal, if you agree: I rewrite the UnixLocal side on _unix_local_file_ops with O_EXCL, drop the shared shell implementation and the write_new_file staging path entirely, and this PR becomes UnixLocal-only with the remaining backends adopting their own primitive separately. That is the first of the two options I offered, now with a much smaller diff. Tell me if you would rather own it yourself and I will stand down.

Nothing pushed for this; it is a design note. I have not rebased because I would rather agree the shape first than resolve a rework of the same file twice.

@ayaangazali
ayaangazali force-pushed the fix/apply-patch-create-existing branch from bd47f64 to 93cb1b3 Compare September 10, 2026 01:42
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Rebuilt on #4931 as proposed, in 93cb1b3. Net effect is 135 insertions against 326 deletions, so this is mostly removal.

What it does now. write_new in _unix_local_file_ops is the existing write with O_TRUNC replaced by O_EXCL. It claims the name in the same syscall that creates the file and fails with EEXIST when the name is taken by a file, a directory, or a dangling symlink, and the surrounding dir_fd plus O_NOFOLLOW handling is yours, not mine. The bound-user path runs the same module in the worker and exits with a distinct status for an existing target so the caller reports FileExistsError instead of a generic write error.

What it deletes. The shell script, the staging file, the hard link, and the staging cleanup. That removes the causes behind the two threads I left open rather than adding conditions to them: with no hard link there is nothing to fail on writable object-storage mounts or across owners under fs.protected_hardlinks, and with no staging entry there is nothing for concurrent work to observe or replace. My parent-normalize plus unresolved-leaf workaround is gone too, since the file ops already do that properly.

The shared base now documents what it actually guarantees: it rejects an occupied target and leaves the existing content alone, it is explicitly not atomic, and a backend that can claim a name should override it. If you want a guaranteed claim on every backend, that is per-backend work I cannot verify for the provider adapters, and I would rather you scope it than have me guess.

One thing you should look at. test_parent_swap_after_validation_cannot_access_outside[patch] injected its swap at session.normalize_path, which the create path no longer calls, so the swap stopped firing and the case was passing for the wrong reason. I re-pointed the patch case at the _files.authorize boundary the create path does traverse, so the injection fires again and the test still asserts swapped. I checked the property independently first rather than assuming: with a parent symlinked outside the workspace, the create is refused with InvalidManifestPathError and the outside file keeps its original bytes. Editing a security test of yours is not something I wanted to do quietly, so please sanity-check that hunk.

Verification, stated precisely. Affected files all pass: test_unix_local_file_io.py 24, test_unix_local_user_file_io.py 20, test_unix_local.py 27, test_apply_patch.py 25, capabilities/test_apply_patch_tool.py 25, test_apply_patch_tool.py 11. Lint clean. I have not re-run the whole suite since the fix because this machine is under a memory limit right now, so I am not claiming a full green; the only failure the last full run produced is the one described above and it is fixed.

Branch is rebased on main at 0.22.2.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93cb1b3046

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 304545645f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
@ayaangazali

Copy link
Copy Markdown
Contributor Author

@seratch this is ready for another look when you have time. Every review thread is resolved, and the shape changed enough since your review that a re-read is probably cheaper than following the thread history.

Where it landed. Exclusive create is now write_new in _unix_local_file_ops, which is your existing write with O_TRUNC replaced by O_EXCL. It claims the name in the syscall that creates the file and fails with EEXIST for a file, a directory, or a dangling symlink. The bound-user path runs the same module in your worker and returns a distinct status for an existing target. Your dir_fd plus O_NOFOLLOW handling from #4931 does the symlink work; I just added the flag variant and wired it.

That deleted the shell script, the staging file, the hard link, and the staging cleanup that my earlier attempts needed. The two findings I had left open, cross-owner hard links under run_as and ownership-safe staging cleanup, are gone with the mechanism rather than patched around.

Source side is 153 lines across four files: 31 in _unix_local_file_ops.py, 58 in unix_local.py, 39 in base_sandbox_session.py, 13 forwarding in sandbox_session.py, and 25 in apply_patch.py. The rest is tests.

The behaviour you asked for is covered: the name is claimed at the backend write boundary, an intervening creator keeps its content, and a dangling symlink at the target name is rejected rather than followed.

Two things I would rather you decided than me.

The shared base default rejects an occupied target but is explicitly not atomic, and its docstring says so. Backends that can claim a name should override it, as UnixLocal now does. If you want a guaranteed claim on every provider adapter, that is per-backend work I cannot verify, and I would rather you scoped it than have me guess.

I edited one of your tests. test_parent_swap_after_validation_cannot_access_outside[patch] injected at session.normalize_path, which the create path no longer calls, so its swap stopped firing and the case was passing for the wrong reason. I re-pointed the patch case at the _files.authorize boundary the create path does traverse. I verified the property independently first: with a parent symlinked outside the workspace the create is refused with InvalidManifestPathError and the outside file keeps its bytes. Please sanity-check that hunk.

CI still shows nothing because the workflow run sits at action_required. Locally the affected files are green and lint is clean.

@ayaangazali
ayaangazali force-pushed the fix/apply-patch-create-existing branch from 06cec0a to 010eb64 Compare September 11, 2026 17:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 010eb64008

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/apply_patch.py
Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10b0e7724a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/testing/sandbox.py Outdated
ayaangazali and others added 14 commits September 12, 2026 03:35
Add File is documented to the model as creating a new file, and the
delete and update operations both enforce their existing-file
precondition. create_file enforced nothing, so an Add File operation
aimed at a path that already existed overwrote it and reported
"Created <path>", losing the previous contents with no error.

Check that the destination is absent before writing, mirroring the
existing _ensure_exists precondition used by delete_file.
…pans

The absence check reached SandboxSession.read(), which records a failed
sandbox.read child span when the file is missing. Missing is the success
case for a create, so every successful Add File looked like it contained
a failed sandbox operation.

Use the existing _read_with_expected_span_errors helper, the same path
the skills capability already uses for an existence probe.
The span assertion needs FilesystemTestSandboxSession, which is typed to
UnixLocalSandboxSessionState, and importing agents.sandbox.sandboxes.unix_local
raises ImportError on Windows by design. tests/conftest.py already
collect-ignores the other files that depend on it, but test_apply_patch.py
must stay collectible because its remaining tests are platform independent.

The unix-only imports stay inside the test body so collection does not
touch unix_local on Windows.
The previous read-then-write check left a window: a concurrent sandbox
command could create the file after the probe, and the unconditional
write then destroyed that content. Reading also did not establish that a
dangling symlink entry was absent, because the unix_local path policy
resolves symlinks and the write landed on the link target.

Add BaseSandboxSession.write_new_file(), which claims the target name
before the payload is written and raises FileExistsError when the name is
already taken. The shared implementation uses a shell noclobber
redirection, so the redirect itself is the O_EXCL attempt; UnixLocal
overrides it with os.open(O_CREAT|O_EXCL) for its direct path. Both
validate the parent through the normal policy, preserving grants and the
bound user, and leave the final component unresolved so a symlink at that
name is rejected rather than followed.

apply_patch create_file now uses it and drops the probe, so the tracing
workaround for the probe read is no longer needed. Existing files still
have to go through update_file.
Three problems with the previous commit.

SandboxSession did not forward write_new_file, so a session built by
SandboxClient fell back to the shared implementation and never reached
the UnixLocal os.open override. Forward it.

The shared implementation created an empty file and then wrote the
payload in a separate step, so a concurrent writer could be overwritten
and a failed upload left an empty file holding the name. Write the
payload under a staging name first, then claim the target with ln, which
fails when the name is taken. The content is complete before the name
exists, and a failed create leaves only the staging entry, which is
removed.

The script used ':' for the noclobber redirection. ':' is a POSIX special
builtin, so on dash a redirection failure ended the shell before the exit
mapping ran and a collision surfaced as a generic write error instead of
FileExistsError. ln is a regular command, and a test now runs the script
through sh, dash and bash so this cannot regress silently.

Also create local files with 0o666 so the process umask decides the final
mode, matching Path.open("wb") on the ordinary write path.
WorkspaceEditor normalizes the destination before dispatching, and
UnixLocal resolves leaf symlinks, so create_file handed the primitive the
link target. Add File on a dangling link.txt created missing.txt and
reported success. Pass the unresolved path for create.

Stage the payload locally too, then os.link it into place. os.link fails
with EEXIST for a file, a directory or a dangling symlink, and a write
that fails partway now leaves only the staging entry instead of a file
holding the name.

Reject a name held by a directory in the shared script. Bare ln treats an
existing directory as a target directory and would have linked the
staging file inside it while reporting the directory as created.

Move the staging write inside the cleanup scope so a failed upload cannot
leak the staging entry, and create the parent as the bound user so a
fresh nested path is owned the way the previous write path owned it.

The new tests drive session.apply_patch() rather than the primitive, which
is the path that was actually broken.
Use sh -c instead of sh -lc for the exclusive create. This path runs for a
filesystem-only capability set, so it must not source shell startup files
that live in the workspace it is editing.

A parent that is a regular file makes mkdir raise FileExistsError, and the
broad handler reported that as a collision on the requested name, telling
the model to use update_file for a target that does not exist. Only the
os.link call can report a collision now; parent and staging failures are
wrapped as write errors.
The staging name was derived from the destination, so it was always
longer than the destination itself. A filename that fits the filesystem's
component limit, and that the ordinary write path accepts, could then
fail to stage: a 254 character name raised WorkspaceArchiveWriteError
where a plain write succeeded before this branch.

The staging basename is now constant at 52 characters regardless of the
destination.

Reported by fscfede-beep in #4930.
The staging write ran before the link script could classify the target, so
a target inside an executable but non-writable parent failed on the
staging write and the caller saw WorkspaceArchiveWriteError instead of the
ApplyPatchDiffError that points it at update_file. It also meant an
Add File onto an occupied name uploaded a payload that was then discarded.

Probe the target first in both paths, then keep the atomic claim to decide
real races. A creator that wins between the probe and the link still loses
the name, and its staging entry is still cleaned up.

Reported by Codex and by fscfede-beep in #4930.
openai#4931 gave UnixLocal a dir_fd plus O_NOFOLLOW file-ops layer and routed the
bound-user path through a Python worker running that same module. That is
the primitive this change was approximating, so use it instead.

write_new is the existing write with O_TRUNC replaced by O_EXCL, which
claims the name in the same syscall that creates the file and fails with
EEXIST when the name is taken by a file, a directory, or a dangling
symlink. The worker exits with a distinct status for an existing target so
the caller can report FileExistsError rather than a generic write error.

This deletes the machinery the previous approach needed: the shell script,
the staging file, the hard link, and the staging cleanup. With no hard link
there is nothing to fail on writable object-storage mounts or across owners
under fs.protected_hardlinks, and with no staging entry there is nothing for
concurrent work to replace. The shared base now documents what it actually
guarantees: it rejects an occupied target but is not atomic, and a backend
that can claim a name should override it.

test_parent_swap_after_validation_cannot_access_outside[patch] injected at
session.normalize_path, which the create path no longer calls, so its swap
stopped firing. Re-pointed the patch case at the file-ops authorize
boundary the create path does traverse, keeping the coverage rather than
letting it pass vacuously. Verified separately that a parent symlinked
outside the workspace is still refused, with the outside file untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the previous commit.

Passing the whole path into the file ops unresolved made a supported
internal symlink parent fail. The ordinary write path resolves those safe
aliases, and test_safe_symlinks_grants_and_listing_paths_remain_supported
establishes the support, but a create through "internal -> real" opened
"internal" with O_NOFOLLOW | O_DIRECTORY and failed. Resolve the parent the
way write does and keep only the leaf name unresolved, so a dangling
symlink at the target name is still rejected without its target being
created.

The shared default probed the target with read(), which eagerly fetches the
whole payload on the remote backends that inherit it. An Add File aimed at
an existing large file would download it before reporting the collision,
and an existing file the bound user cannot read reported a read failure
instead of the collision. List the parent instead, which needs no payload
and no read permission on the target.

Both regressions have a test that fails on the unfixed source, including
one asserting the rejected create issues no read at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Listing the parent to decide a collision failed when the parent did not
exist. BaseSandboxSession.ls() raises ExecNonZeroError in that case, which
the probe did not catch, so a nested create such as newdir/file.txt aborted
before write() on every backend inheriting the default, while succeeding on
UnixLocal. Those backends' write paths create missing parents.

Use a bare existence test instead. It needs only execute permission on the
parent, never reads the target, and reports absent when the parent is
missing so the create still reaches write(). Only a positive result rejects;
any other probe outcome falls through rather than blocking a valid create.

The test is also more accurate than the listing: it sees a file inside an
executable but unreadable parent, which listing could not, so an existing
file there is no longer silently overwritten.

Validated the exact shipped script under sh, bash and dash for an existing
file, a dangling symlink, an absent name, a missing parent, and a file in a
0311 parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… working

Two problems with the previous commit.

The probe treated any unexpected status as "absent" so a valid create would
not be blocked. That was wrong: exit 0 already means absent, including when
the parent does not exist, so the permissive fallback only covered the case
where the probe itself did not run. On provider sessions whose write() uses
a separate upload API, the write then succeeds and overwrites an existing
target precisely when the precondition could not be checked. Only 0 may
proceed now; 13 stays the collision and every other status is a write error.

scripted_sandbox_session() broke. The inherited default reaches for exec,
and a script that configures only file steps hides exec, so a previously
valid mkdir-and-write script failed with AttributeError. Give the scripted
session an implementation that consumes the same mkdir and write pair, and
restore the explicit parent creation in the default so the observable call
sequence matches what the create path did before this branch.

Both have a test that fails on the unfixed source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The create path hands write_new_file an unresolved path on purpose, so a
symlink at the target name is rejected rather than followed. The scripted
session forwarded that path straight into its compatibility mkdir and write
pair, so a script using the documented match callback saw "." and
"notes.txt" where it previously saw the workspace paths, and failed with
SandboxCallMatcherError.

Normalize before emitting the pair. Verified against the previous commit:
with a custom manifest root the calls were "sub" and "sub/notes.txt" and
are now "/custom/root/sub" and "/custom/root/sub/notes.txt", matching what
this surface recorded before this branch.

The earlier test only asserted method names, which is why it could not catch
this. It now asserts the recorded paths too and fails on the unfixed source.
@ayaangazali
ayaangazali force-pushed the fix/apply-patch-create-existing branch from 10b0e77 to 57f8ae3 Compare September 12, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants