diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.4.0.1] - 2026-10-27
+
+### Fixed
+
+- A write through a dangling symlink no longer escapes the workspace.
+  `sandboxPath` tested path existence with `doesPathExist`, which stats
+  through symlinks and so reported a link whose target does not exist as
+  absent. Such a link was treated as an ordinary new file: the containment
+  check ran on the link's own path, and the tool's later open followed the
+  link. A workspace holding `output -> /tmp/outside`, with the target
+  missing, let `writefile output` create `/tmp/outside`. Link components are
+  now lstat'd whether or not they resolve, so a dangling link is resolved to
+  its target before the containment check and the chain walk rejects a
+  dangling link planted after it. A dangling link whose target is inside the
+  workspace still resolves, and now yields the target's path rather than the
+  link's.
+
 ## [0.4.0.0] - 2026-10-24
 
 ### Fixed
@@ -410,6 +427,7 @@
   DeepSeek), single-shot generation with fallbacks, agent tool loops, structured
   output, JSON model catalog loading, and workspace-scoped filesystem tools.
 
+[0.4.0.1]: https://github.com/aische/llm-simple/compare/v0.4.0.0...v0.4.0.1
 [0.4.0.0]: https://github.com/aische/llm-simple/compare/v0.3.0.0...v0.4.0.0
 [0.3.0.0]: https://github.com/aische/llm-simple/compare/v0.2.0.0...v0.3.0.0
 [0.2.0.0]: https://github.com/aische/llm-simple/compare/v0.1.1.0...v0.2.0.0
diff --git a/Readme.md b/Readme.md
--- a/Readme.md
+++ b/Readme.md
@@ -256,7 +256,7 @@
 
 ### Filesystem sandbox
 
-Filesystem tools enforce **workspace-relative path containment**: `..` escapes, absolute paths outside the workspace, and symlinks pointing outside are rejected. Recursive tools skip symlinks rather than following them.
+Filesystem tools enforce **workspace-relative path containment**: `..` escapes, absolute paths outside the workspace, and symlinks pointing outside are rejected. A symlink is judged by where it points, including when its target does not exist yet: writing through a dangling link that leads out of the workspace is refused and does not create the target. A link that resolves inside the workspace is followed, and the operation lands on its target. Recursive tools skip symlinks rather than following them.
 
 **Limitations (0.1.x):**
 
diff --git a/llm-simple.cabal b/llm-simple.cabal
--- a/llm-simple.cabal
+++ b/llm-simple.cabal
@@ -2,7 +2,7 @@
 
 name:               llm-simple
 
-version:            0.4.0.0
+version:            0.4.0.1
 
 synopsis:
     Multi-provider LLM library with agent tool loops and filesystem tools
diff --git a/src/LLM/Tools/FsConfig.hs b/src/LLM/Tools/FsConfig.hs
--- a/src/LLM/Tools/FsConfig.hs
+++ b/src/LLM/Tools/FsConfig.hs
@@ -60,6 +60,10 @@
 -- If the path doesn't exist yet (e.g. for writes), the longest
 -- existing ancestor is canonicalized (so symlinks in the parent
 -- chain are resolved) and the missing tail is appended verbatim.
+--
+-- A symlink counts as existing even when its target does not, so a
+-- dangling link is resolved to that target and judged by where it
+-- points rather than appended as if it were an ordinary new file.
 -- Throws 'SandboxViolation' on escape attempts.
 sandboxPath :: FsConfig -> FilePath -> IO FilePath
 sandboxPath cfg relPath = do
@@ -134,16 +138,20 @@
 -- @baseJoined@ is the canonical base directory (without trailing
 -- separator); @tailComps@ are the components below it that must be
 -- traversed.
+--
+-- Every component is lstat'd ('isSymlink') whether or not it resolves:
+-- a dangling link must be rejected here too, since it is still a link
+-- that a later @open@ would follow. 'isSymlink' already answers 'False'
+-- for a component that does not exist at all, so no existence test
+-- guards it.
 verifyNoSymlinksInChain :: FilePath -> FilePath -> [FilePath] -> IO ()
 verifyNoSymlinksInChain base = walk
   where
     walk _ [] = pure ()
     walk acc (c : cs) = do
       let next = acc </> c
-      exists <- doesPathExist next
-      when exists $ do
-        sym <- isSymlink next
-        when sym $ throwIO (SandboxViolation next base)
+      sym <- isSymlink next
+      when sym $ throwIO (SandboxViolation next base)
       walk next cs
 
 -- | Canonicalize the longest existing prefix of a path, then append
@@ -154,9 +162,16 @@
 -- Uses 'splitFileName' to peel the trailing component instead of a
 -- character-level @drop (length parent)@, which is brittle across
 -- trailing-separator and platform-separator variations.
+--
+-- Existence is tested with 'pathExistsNoFollow' rather than
+-- 'doesPathExist': a dangling symlink must be handed to
+-- 'canonicalizePath' (which resolves it to its missing target) instead
+-- of being mistaken for an ordinary nonexistent file whose own name is
+-- then appended verbatim. Getting that wrong let a write through a
+-- dangling link land outside the sandbox.
 canonicalizeExisting :: FilePath -> IO FilePath
 canonicalizeExisting path = do
-  exists <- doesPathExist path
+  exists <- pathExistsNoFollow path
   if exists
     then canonicalizePath path
     else do
@@ -190,6 +205,15 @@
 isFileHidden path = case path of
   ('.' : _) -> True
   _ -> False
+
+-- | Whether a directory entry exists at @p@, without following a final
+-- symbolic link. 'doesPathExist' stats through links and so reports
+-- 'False' for a dangling one; such a link is still an entry that an
+-- @open@ would follow, so it must count as existing here.
+pathExistsNoFollow :: FilePath -> IO Bool
+pathExistsNoFollow p = do
+  exists <- doesPathExist p
+  if exists then pure True else isSymlink p
 
 -- | Check whether a path is a symbolic link (without following it).
 -- Returns 'False' if the path doesn't exist or can't be stat'd.
diff --git a/test/LLM/FsConfigSpec.hs b/test/LLM/FsConfigSpec.hs
--- a/test/LLM/FsConfigSpec.hs
+++ b/test/LLM/FsConfigSpec.hs
@@ -13,6 +13,7 @@
     createDirectoryLink,
     createFileLink,
     doesFileExist,
+    doesPathExist,
   )
 import System.FilePath ((</>))
 import System.IO.Temp (withSystemTempDirectory)
@@ -133,6 +134,39 @@
         -- permitted and the resolved path points at the real file.
         resolved <- sandboxPath cfg "link/f.txt"
         resolved `shouldBe` (sb </> "real" </> "f.txt")
+
+    it "rejects a dangling final-component symlink pointing outside" $
+      withSystemTempDirectory "llm-simple-fsconfig" $ \root -> do
+        let sb = root </> "sandbox"
+            outside = root </> "outside"
+        createDirectory sb
+        createDirectory outside
+        -- The target does not exist, so 'doesPathExist' reports the link
+        -- as absent; only an lstat sees it.
+        createFileLink (outside </> "new.txt") (sb </> "dangling")
+        cfg <- mkFsConfig sb
+        shouldViolate (sandboxWritePath cfg "dangling")
+        doesPathExist (outside </> "new.txt") >>= (`shouldBe` False)
+
+    it "rejects a write below a dangling directory symlink pointing outside" $
+      withSystemTempDirectory "llm-simple-fsconfig" $ \root -> do
+        let sb = root </> "sandbox"
+            outside = root </> "outside"
+        createDirectory sb
+        createDirectory outside
+        createDirectoryLink (outside </> "missing") (sb </> "dangdir")
+        cfg <- mkFsConfig sb
+        shouldViolate (sandboxWritePath cfg "dangdir/new.txt")
+        doesPathExist (outside </> "missing") >>= (`shouldBe` False)
+
+    it "allows a dangling symlink whose target is inside the sandbox" $
+      withSandbox $ \sb -> do
+        createFileLink (sb </> "real-new.txt") (sb </> "dangling")
+        cfg <- mkFsConfig sb
+        -- Resolving the link keeps the write inside the workspace, so it
+        -- is permitted and lands on the link's target, not the link.
+        resolved <- sandboxWritePath cfg "dangling"
+        resolved `shouldBe` (sb </> "real-new.txt")
 
   describe "sandboxWritePath: non-existent targets" $ do
     it "resolves a write path under a not-yet-existing nested dir" $ withSandbox $ \sb -> do
diff --git a/test/LLM/FsToolsSpec.hs b/test/LLM/FsToolsSpec.hs
--- a/test/LLM/FsToolsSpec.hs
+++ b/test/LLM/FsToolsSpec.hs
@@ -1,6 +1,6 @@
 module LLM.FsToolsSpec (spec) where
 
-import Control.Exception (bracket_, evaluate)
+import Control.Exception (bracket_, evaluate, try)
 import Data.Aeson (Value, object, (.=))
 import Data.ByteString qualified as BS
 import Data.Map qualified as Map
@@ -13,6 +13,7 @@
 import LLM.Generate.GenerateUtils (llmHooks)
 import LLM.Generate.Logger (noHooks)
 import LLM.Load.FsTools (fsTools)
+import LLM.Tools.FsConfig (SandboxViolation (..))
 import System.Directory
   ( createDirectory,
     createDirectoryLink,
@@ -29,6 +30,7 @@
 import Test.Hspec
   ( Spec,
     describe,
+    expectationFailure,
     it,
     shouldBe,
     shouldContain,
@@ -213,6 +215,20 @@
       pathIsSymbolicLink (sb </> "link") `shouldReturn` True
       readFile (sb </> "real.txt") `shouldReturn` "keep"
       doesPathExist (sb </> "moved.txt") `shouldReturn` False
+
+    it "writefile refuses a dangling link pointing outside the workspace" $ withSandbox $ \sb -> do
+      tm <- fsTools sb
+      -- The link target does not exist yet, so the link looks absent to
+      -- every check that stats through it; the write must still not
+      -- create it. The agent loop turns the violation into a model-facing
+      -- message, so here it surfaces as the exception itself.
+      let outside = takeDirectory sb </> "outside.txt"
+      createFileLink outside (sb </> "dangling")
+      r <- try (runTool tm "writefile" (object ["path" .= ("dangling" :: Text), "content" .= ("pwned" :: Text)]))
+      case r of
+        Left (SandboxViolation _ _) -> pure ()
+        Right _ -> expectationFailure "expected SandboxViolation but writefile succeeded"
+      doesPathExist outside `shouldReturn` False
 
     it "copy_file refuses a link source" $ withSandbox $ \sb -> do
       tm <- fsTools sb
