packages feed

llm-simple 0.4.0.0 → 0.4.0.1

raw patch · 6 files changed

+100/−8 lines, 6 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

Files

CHANGELOG.md view
@@ -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
Readme.md view
@@ -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):** 
llm-simple.cabal view
@@ -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
src/LLM/Tools/FsConfig.hs view
@@ -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.
test/LLM/FsConfigSpec.hs view
@@ -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
test/LLM/FsToolsSpec.hs view
@@ -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