TL;DR
- CVE-2026-103916 is a symlink escape in the
sbx cpcommand of Docker Sandboxes (CWE-59). A sandboxed agent plants a symlink inside the shared workspace, and a later, user-initiated copy reads or writes a host file outside it. - Affected: versions 0.28.0 up to but not including 0.51.0. Fixed in 0.51.0, released November 3, 2026. An earlier fix scheduled for 0.48.0 (October 13) was incomplete, according to the Endor Labs write-up.
- The root cause is a coverage problem, not a missing idea. The
-Lstyle protection covered the final component of the copy-in source, which leaves three of four symlink positions unguarded. - I built a small runnable model of the bug class. A guard on one position blocks 1 of 4 escapes. A guard that walks every component blocks all 4.
- The lesson for anyone building agent gates: a control is only as strong as the number of positions it covers, so enumerate the positions before you declare the gate done.
What actually happened in CVE-2026-103916?
Docker Sandboxes runs coding agents in isolated environments and shares a workspace directory between the host and the sandbox. The sbx cp command copies files between the two. The CVE description says the command does not safely confine host-side paths to the shared workspace. A malicious sandbox can create or replace a workspace symlink so that a later user-initiated copy either reads a host file outside the workspace into the sandbox, or writes sandbox-controlled data to a host path outside the workspace, with the invoking user's permissions.
Two details matter for threat modeling.
First, the agent does not run the dangerous command. It only prepares the filesystem. The operator then runs a perfectly ordinary sbx cp against what looks like an approved path. That makes the operator the confused deputy: they hold the authority, the agent supplies the poisoned input, and the tool never checks that the two line up.
Second, the severity is high but not exotic. Endor Labs, whose researcher Peyton Kennedy found the bug, reports CVSS 3.1 of 8.2 with a vector that requires local access, low privileges and user interaction. Other listings show 8.4, so treat the exact number as listing-dependent and the "high" band as the stable fact.
Sources: the Endor Labs write-up and the Rapid7 entry in the Vulners database.
Why did a fix ship and still leave the hole open?
This is the part worth studying. The timeline in the Endor Labs report goes like this:
- An earlier finding, CVE-2026-17106 (fixed in 0.38.0), was a destination escape through archive paths. The containment fix for it did not address copy-in source paths.
- A fix for the newer finding was scheduled for 0.48.0 on October 13, 2026, but it was incomplete.
- The complete fix landed in 0.51.0 on November 3, 2026. That release makes
sbx cpblock symbolic links in mounted directories from escaping onto other host paths.
The report breaks the surface into four symlink positions. The -L flag protected only the final path component during copy-in. The other three were open:
| Position | Guarded before 0.51.0? |
|---|---|
| Copy-in source, last component | Yes (the -L check) |
| Copy-in source, parent component | No |
| Copy-out destination, last component | No |
| Copy-out destination, parent component | No |
That table is the whole story. A symlink can sit at the leaf, or at any directory above the leaf, and it can sit on the source side or the destination side of a copy. Two sides times two kinds of position gives four. A fix that handles the case someone happened to demonstrate, and not the shape of the problem, will keep failing in the neighbouring cell.
How can you model this bug class without Docker?
You do not need the product to understand the failure. The model below creates a workspace, a "host secret" directory outside it, and an attacker-controlled payload file. It then tries all four positions against two guards. The partial guard refuses a symlink at the final component of the copy-in source and nothing else. The full guard walks every component of the path from the workspace root and also checks that the resolved path stays inside the root.
This is my own model of the bug class. It is not Docker's code, and it does not claim to reproduce their implementation.
import os, shutil def inside(root, p): root = os.path.realpath(root) p = os.path.realpath(p) return p == root or p.startswith(root + os.sep) def guard_final_only(path): if os.path.islink(path): raise PermissionError("final component is a symlink") def guard_all_components(root, path): rel = os.path.relpath(os.path.abspath(path), os.path.abspath(root)) cur = os.path.abspath(root) for part in rel.split(os.sep): cur = os.path.join(cur, part) if os.path.islink(cur): raise PermissionError("symlink at " + cur) if not inside(root, path): raise PermissionError("resolves outside root") def copy_in(root, src, dst, guard): guard(root, src) shutil.copyfile(src, dst) def copy_out(root, src, dst, guard): guard(root, dst) shutil.copyfile(src, dst) The harness builds each scenario in a temporary directory. For example, the copy-in parent case plants workspace/vendor as a symlink to the directory that holds the secret, then asks for workspace/vendor/creds.txt:
os.symlink(outside, os.path.join(ws, "vendor")) copy_in(ws, os.path.join(ws, "vendor", "creds.txt"), dst, guard) escaped = os.path.exists(dst) and open(dst).read() == "SECRET" I ran it on Linux (symlink creation needs a privilege that a default Windows account lacks). The result for each position:
| Position | Partial guard | Full-path walk |
|---|---|---|
| Copy-in source, last component | blocked | blocked |
| Copy-in source, parent | escaped | blocked |
| Copy-out destination, last component | escaped | blocked |
| Copy-out destination, parent | escaped | blocked |
The partial guard passes the one test its author thought of and fails the other three. If your test suite only contains the case from the bug report, it will look green here too, which is exactly how an incomplete fix gets shipped.
What does the full-path check still miss?
My model is deliberately small, and it leaves out at least three things a production fix has to handle.
Time-of-check to time-of-use. The agent keeps running in the sandbox while the copy executes. A check that resolves the path and then opens it later leaves a window in which the agent can swap a directory for a symlink. The robust pattern is to resolve and open in one step, for example by walking the path with directory file descriptors and refusing to follow links at each hop, rather than validating a string and trusting it afterwards. A recent arXiv survey on execution security for coding agents groups isolation, access control and time-of-check-to-time-of-use bugs as one family of problems for this reason.
Hard links and bind-mount tricks. A symlink check says nothing about a hard link to a file the agent should not own, or about a mount that changes what a path means after you resolved it.
Archive extraction. The earlier CVE in this chain, CVE-2026-17106, involved archive paths. Anything that unpacks agent-produced data on the host is a copy-out with extra steps and needs the same walk.
What should agent builders take from this?
The previous posts in this series covered individual gate bypasses: a trailing dot that changed which path the OS opened, and a newline that let hidden commands through a read-only fast path. This one adds a structural point about how those gates get built.
1. Enumerate positions before you write the check.
For any boundary, list every place attacker-controlled input can influence the decision: source and destination, leaf and ancestors, first call and retry. Write the list in the test file. The Docker case had a four-cell grid and a one-cell fix.
2. Test the neighbours of every reported bug.
When a vulnerability report arrives, generate the sibling cases mechanically. Swap source and destination. Move the symlink from the leaf to the parent. Add a case for each. A fix is done when the whole grid is red-then-green, not when the reproducer passes.
3. Treat the human as part of the attack path.
The agent never ran sbx cp. It arranged for someone else to run it. Any command a person types against agent-writable state inherits the agent's influence over that state. If your docs tell users to review what the agent proposes, tell them also to check what the paths in that command point at, for instance with realpath before copying.
4. Prefer structural isolation to string checks.
A policy that says "paths must start with the workspace prefix" is a string property. The operating system resolves a different object than the string names. Where possible, copy through a channel the agent cannot write to, or into directories that were never mounted into the sandbox, which is also the stopgap Endor Labs recommends for unpatched versions.
5. Keep a changelog of coverage, not only of fixes.
"Fixed symlink escape" is a statement about one case. "Guards symlinks at all four positions; time-of-check races covered by open-time resolution" is a statement about coverage, and a reviewer can challenge it.
How do you protect yourself today?
- Upgrade Docker Sandboxes to 0.51.0 or later if you are on 0.28.0 through 0.50.x.
- Until you can, resolve both ends of every copy with
realpathand confirm the result stays inside the approved workspace. Check parent directories, not only the file name. - Prefer copying to and from directories that are not mounted into the sandbox.
- Treat any copy command that an agent suggests, and any path in the workspace the agent can write to, as untrusted input.
- Add the four-position grid, or your product's equivalent, to your own sandbox regression tests.
FAQ
What is CVE-2026-103916 in one sentence?
A flaw in the Docker Sandboxes sbx cp command where a symlink planted in the shared workspace lets a later user-initiated copy read or overwrite host files outside the approved workspace.
Which versions are affected and which one fixes it?
Versions from 0.28.0 up to but not including 0.51.0 are affected. Version 0.51.0, released November 3, 2026, includes the fix that blocks symlinks in mounted directories from escaping to other host paths.
Does the agent need to run any special command to exploit it?
Not according to the reports. The sandboxed agent only creates or replaces a symlink in the workspace. The exploit completes when the operator runs a normal sbx cp against that path.
Why is checking only the last path component not enough?
Because a symlink can appear at any directory above the leaf, and on either the source or the destination side of a copy. Guarding the leaf of one side covers one of four positions, which is what the report describes for the -L check.
Is a realpath check a complete defense?
It is a good stopgap for manual use, but a resolve-then-open sequence can still lose a race if the agent swaps the path in between. A durable fix resolves and opens atomically, for example with per-component directory handles that refuse to follow links.
Does this bug class only affect Docker?
No. Any tool that copies, extracts or opens paths inside a directory an untrusted agent can write to has the same shape. The four-position grid applies to all of them.
Further reading
Earlier posts in this series:
- A Trailing Dot Beat the Agent's Edit Gate: Check the Path the OS Will Open, Not the String (published 2026-11-03, same series)
- The Model Is Not a Security Boundary: What Two Semantic Kernel CVEs Teach About Gating Tool Arguments (published 2026-10-21, same series)
