fix(sandbox): normalize UnixLocal snapshot special files and symlinks - #4831
Conversation
UnixLocalSandboxSession.persist_workspace() archived the workspace
with tarfile.add() unchanged, while hydrate_workspace() extracts with
the strict policy that refuses hardlink members, FIFOs/device nodes and
absolute symlink targets. Ordinary workspaces hit all three: uv and
pnpm hardlink installed packages, dev servers leave FIFOs behind, and
`ln -s "$PWD/file" link` writes an absolute target. The snapshot was
taken successfully and then could never be restored
("hardlink member not allowed", "unsupported member type",
"absolute symlink target not allowed: /tmp/sandbox-local-.../file").
Rewrite members while archiving: store hardlinks as regular files,
drop FIFOs and device nodes, and turn an absolute symlink target that
stays under the workspace root into a relative one so it also survives
the root moving between sessions. Absolute targets outside the
workspace are left unchanged; hydrate keeps rejecting them by design.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BN4v25msJgrjNgac97g1Az
… a double slash os.path.normpath keeps two leading slashes, so //<root>/a.txt was not recognized as under the workspace root and stayed absolute; Linux resolves // as /, so collapse it before the containment check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Normalising at archive creation is the right boundary because hydrate already has a strict, security-relevant contract. Converting hardlinks to payload-bearing regular members and relativising only absolute targets proven inside the workspace preserves that contract without weakening extraction.
seratch
left a comment
There was a problem hiding this comment.
Thanks for the contribution. Fixing archive creation is the right direction, and the hardlink restore failure warrants a focused fix.
Before merging, please correct the absolute-symlink rewrite so it preserves path semantics. With current -> releases/v1, a link to <root>/current/../config refers to <root>/releases/config, but normpath() collapses it to <root>/config. The restored link can therefore silently read a different file.
Please preserve the target components when replacing the workspace-root prefix, avoiding normalization that collapses .. across symlink components. Add a persist/hydrate regression with different contents at those two paths and assert that the restored link reads the original contents.
…r components Replacing the workspace-root prefix of an absolute symlink target went through normpath(), which collapses `..` lexically. The kernel resolves `..` after a symlink component against the link target, so `<root>/current/../config` with `current -> releases/v1` names `releases/config`, and the normalized `config` silently retargeted the restored link. Keep the target's components verbatim and only climb out of the link's own archive directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
|
Thanks — you're right that normpath() changed the meaning. Fixed in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e1806d47c
ℹ️ 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".
…t when rebasing symlinks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFSMrLZZ3oq1dKmJFAofz6
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Re-reviewed current ede33d1 after the symlink rewrite fix and rebase. Absolute in-workspace targets are now rebased without collapsing .. across symlink components, the regression pins the current -> releases/v1 semantic case, and the later separator-run fix preserves the same boundary handling. Current CI and Codex/security reviews are clean. No remaining blocker from me.
…the workspace The rebase keeps the components after the root verbatim, so `a/link/../tmp` is only inside the workspace if `a/link` resolves there. With `a/link -> ..` it names `/tmp` once restored, while the strict extractor's lexical check accepts the relative form: an absolute target hydrate would have refused became one it lets through. Before rewriting, walk the target through the workspace's own relative links, applying `..` to a link's target the way the kernel does. Leaving the root, a hop through a link whose target is absolute (which proves nothing about the restored tree even when the live tree leads back inside), or exceeding the ELOOP budget keeps the target absolute, so hydrate refuses it exactly as before this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRNVNFM6bqUNRQBnw5GBVC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRNVNFM6bqUNRQBnw5GBVC
|
One more commit, |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Re-reviewed current 53638a7. The new source-workspace symlink walk closes the archive-owned containment case, but the destination-state gap identified on #4834 also applies here. UnixLocalSandboxSession.hydrate_workspace() extracts into an existing root, while _symlink_target_stays_under() proves containment only against the source workspace at persist time. A path component absent from the source snapshot can already be a symlink in the destination and change where the rebased target resolves after hydration. safe_extract_tarfile() checks existing symlinks in the destination path of the new link itself, but not symlinks later traversed by that link's target. Please account for destination state during hydration, or only rebase targets whose intermediate components are established by the snapshot itself. This remains a blocker for the strict external-target guarantee.
… establishes hydrate_workspace() extracts into an existing root, so a component the snapshot does not create may already be a symlink in the destination: a persisted `victim -> <root>/alias/../secret` rebased to `alias/../secret` resolves elsewhere when the destination holds `alias -> /tmp/sub`. The containment walk now requires each component to be established by the snapshot itself: present in the workspace, not excluded by the persist skip list, a directory unless it is the leaf, and the leaf a regular file or directory. Those are the paths the extractor guards against pre-existing destination symlinks. Relative links are still followed and `..` applied to their targets; anything else keeps its absolute form for hydrate to refuse as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRNVNFM6bqUNRQBnw5GBVC
|
Thanks, addressed in |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Re-reviewed current 04c7d513. The destination-state containment gap is closed by requiring every rebased target component to be established by the snapshot itself: present, not skipped, and directory-shaped on intermediate steps, while relative source-workspace links are still resolved before the proof succeeds. Targets that cannot be proven safe keep their absolute form for hydration to reject. The new regression covers missing, dangling, file, skipped, and valid in-snapshot paths. No remaining blocker from my review.
|
This PR is stale because it has been open for 10 days with no activity. |
|
Rechecked current |
jbeckwith-oai
left a comment
There was a problem hiding this comment.
Maintainer assessment of 04c7d51388f2a03b20ecb59ff74a3ac924351fc5 against the full merge-base diff and current main (c42f3c6e).
Need status: demonstrated, with the hardlink portion now already covered. A successfully persisted workspace can fail strict hydration because its archive contains hardlinks, FIFOs, or absolute workspace-local symlinks. The contributor's reported persist/hydrate cases and the producer/extractor paths establish the mismatch. This is a worthwhile snapshot-reliability problem. However, #5188 has now merged the hardlink fix and validation before clearing the live workspace, so this PR is no longer needed for those outcomes.
Recommendation: merge-worthy after focused changes. Repository readiness: rebase or conflict resolution required. Please rebase onto current main and remove this branch's duplicate hardlink conversion, using #5188's archive filter and retaining its pre-clear validation. The remaining change should cover FIFO/device omission and conservative relocation of eligible internal absolute symlinks, with the corresponding tests. This is an integration/scope adjustment, not an additional runtime defect found in the submitted implementation.
Implementation review: no actionable defects found in the complete two-file diff after two rounds, each with independent correctness and architecture reviewers. I inspected archive production, exclusion handling, strict validation/extraction, snapshot lifecycle, cancellation ownership, and the changed tests. The latest code addresses the earlier separator, symlink/parent-step, and destination-state review concerns. The tests inspect emitted payloads and perform persist/hydrate round trips into a new root. Public signatures and the strict external-link policy from #3094 remain unchanged; this does not make arbitrary symlink layouts or system-target links restorable.
Caller-created relative links and workspace cleanup can work when applications control every generated entry, but they do not automatically repair snapshots of ordinary generated workspaces. Exclusions also omit content. Producer-side normalization is the appropriate layer; weakening hydration or introducing a general resolver is unnecessary. The current bounded walk has a concrete role in preserving link semantics and checking snapshot-owned target components.
Validation limits and next action: this was a desk review, including test-source inspection, not execution of PR code, tests, or runtime probes. Contributor-reported test results were not independently reproduced. Current-head GitHub workflows for Tests and CodeQL report action_required; there are no completed check-run results establishing a passing gate. After the focused rebase, the new head needs review and passing required CI before merge. No fixes or other PR changes were made by this review.
|
Addressed the scope/integration recommendation in Two independent final reviews passed. Local formatting, lint, mypy, pyright, and the full test stack passed (11,324 tests passed, 66 skipped). The PR description now reflects the final implementation. Tests and CodeQL have been approved to run on this head; CI is being monitored. |
markstuart-oai
left a comment
There was a problem hiding this comment.
Reviewed be23af510c4de3d830835e565d8ede6fa77df1f5. The FIFO/device handling and reuse of the existing hardlink filter look sound. One archive-integrity issue remains in the symlink proof: it needs to establish the topology that will actually be restored.
Source-only review; all 21 hosted checks passed on this commit. The concurrency case below is established by source tracing and was not executed locally.
markstuart-oai
left a comment
There was a problem hiding this comment.
Re-reviewed 08b98fdf92a75f98104df11371421714f7de2f1f. The archive-topology finding is addressed: containment is now checked against the completed captured member graph, and copied symlink headers keep earlier rewrites from changing later proofs. The deterministic regressions cover both capture orders and verify that rejected archives leave the destination unchanged.
No remaining actionable findings in the full two-file diff. Existing hardlink handling, exclusions and strict hydration validation are preserved. Source-only review; all 21 hosted checks passed on this exact commit. I did not run tests locally.
Dismissing this earlier changes-requested review at the maintainer’s request. The archive-topology finding was addressed in 08b98fd with deterministic regression coverage; the thread is resolved, all 21 CI checks pass, and markstuart-oai and seratch have approved this exact commit.
Summary
This pull request fixes UnixLocal workspace snapshots containing FIFOs or eligible absolute symlinks to files inside the workspace. Persistence now omits FIFO/device entries and rebases eligible internal symlink targets so the archive can hydrate into a different workspace root.
Symlink headers are deferred until archive capture completes. The rebase preserves symlink and parent-step semantics and proves containment against the original captured member/link graph, so source mutations during traversal cannot approve a different topology. File payloads are written once. External, dangling, excluded, cyclic, or otherwise unprovable targets retain the existing strict hydration behavior from #3094. Public signatures and hydration policy are unchanged.
The branch incorporates current
mainand reuses the hardlink archive filter and validation before clearing the live workspace from #5188. Duplicate hardlink conversion and its redundant branch-specific assertions have been removed.Test plan
Issue number
No new closing issue. Hardlink handling is already covered by #5188; Docker's corresponding archive behavior is tracked separately in #4834.
Checks
.agents/skills/code-change-verification/scripts/run.sh