llm-simple-0.1.0.1: src/LLM/Tools/FsConfig.hs
module LLM.Tools.FsConfig
( FsConfig (..),
SandboxViolation (..),
formatSandboxViolation,
mkFsConfig,
sandboxPath,
sandboxWritePath,
isFileHidden,
isSymlink,
)
where
import Control.Exception (Exception, IOException, throwIO, try)
import Control.Monad (when)
import Data.List (foldl')
import Data.Text (Text)
import Data.Text qualified as T
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesPathExist, pathIsSymbolicLink)
import System.FilePath (addTrailingPathSeparator, dropTrailingPathSeparator, equalFilePath, joinPath, normalise, splitDirectories, splitFileName, takeDirectory, (</>))
-- | Configuration for file-system tools.
-- 'fsBasePath' must be a canonical absolute path (use 'mkFsConfig').
newtype FsConfig = FsConfig
{ fsBasePath :: FilePath
}
deriving (Show)
data SandboxViolation = SandboxViolation
{ svAttempted :: FilePath,
svBasePath :: FilePath
}
deriving (Show)
instance Exception SandboxViolation
-- | Model-facing message for a sandbox escape attempt. Paths are shown
-- relative to the workspace root so host layout is not leaked.
formatSandboxViolation :: SandboxViolation -> Text
formatSandboxViolation (SandboxViolation attempted base) =
case stripPathPrefix
(splitDirectories (dropTrailingPathSeparator base))
(splitDirectories attempted) of
Just [] -> "Sandbox violation: path is outside the workspace"
Just relComps ->
"Sandbox violation: path is outside the workspace: "
<> T.pack (joinPath relComps)
Nothing ->
"Sandbox violation: path is outside the workspace"
-- | Create an 'FsConfig' by canonicalizing the given base directory.
-- The directory must already exist.
mkFsConfig :: FilePath -> IO FsConfig
mkFsConfig dir =
FsConfig . addTrailingPathSeparator <$> canonicalizePath dir
-- | Resolve a (possibly relative) path against the sandbox base,
-- canonicalize it, and verify it stays within the sandbox.
-- 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.
-- Throws 'SandboxViolation' on escape attempts.
sandboxPath :: FsConfig -> FilePath -> IO FilePath
sandboxPath cfg relPath = do
let base = cfg.fsBasePath
candidate = collapseDots (normalise (base </> relPath))
canonical <- canonicalizeExisting candidate
let baseComps = splitDirectories (dropTrailingPathSeparator base)
canonComps = splitDirectories canonical
case stripPathPrefix baseComps canonComps of
Nothing -> throwIO (SandboxViolation canonical base)
Just tailComps -> do
-- TOCTOU mitigation: the canonical path has symlinks resolved as of
-- the moment of 'canonicalizeExisting'. Between that check and the
-- tool's subsequent open, an attacker (or a concurrent process
-- inside the sandbox) could replace a path component with a symlink
-- pointing outside. Re-walk the live chain from 'base' to
-- 'canonical' and refuse any symlink encountered: since the
-- canonical chain should not contain symlinks by construction, any
-- symlink found here is a fresh injection. Note: this narrows but
-- does not fully close the TOCTOU window; a complete fix requires
-- fd-relative ('openat') APIs not available in the portable
-- 'directory' package.
verifyNoSymlinksInChain base (joinPath baseComps) tailComps
pure canonical
-- | Element-wise path-prefix comparison.
-- Returns the trailing components of @full@ after stripping @prefix@,
-- or 'Nothing' if @prefix@ is not a path-prefix of @full@.
-- Uses 'equalFilePath' so the comparison respects platform conventions
-- (case-insensitive on Windows, trailing-separator-insensitive, etc.).
-- This avoids the @\"\/sandbox\"@ vs @\"\/sandbox2\"@ string-prefix
-- foot-gun of 'Data.List.isPrefixOf'.
stripPathPrefix :: [FilePath] -> [FilePath] -> Maybe [FilePath]
stripPathPrefix [] ys = Just ys
stripPathPrefix _ [] = Nothing
stripPathPrefix (p : ps) (y : ys)
| equalFilePath p y = stripPathPrefix ps ys
| otherwise = Nothing
-- | Walk every path component of @target@ that lies beneath @base@ and
-- throw 'SandboxViolation' if any component is a symbolic link.
-- @baseJoined@ is the canonical base directory (without trailing
-- separator); @tailComps@ are the components below it that must be
-- traversed.
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)
walk next cs
-- | Canonicalize the longest existing prefix of a path, then append
-- any missing trailing components. This ensures that even when the
-- final target doesn't exist yet, symlinks in the parent chain are
-- resolved before the containment check.
--
-- 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.
canonicalizeExisting :: FilePath -> IO FilePath
canonicalizeExisting path = do
exists <- doesPathExist path
if exists
then canonicalizePath path
else do
let (parentRaw, name) = splitFileName path
parent = dropTrailingPathSeparator parentRaw
if null name || equalFilePath parent path
then pure path
else do
parentCanon <- canonicalizeExisting parent
pure (parentCanon </> name)
-- | Like 'sandboxPath', but also creates parent directories
-- inside the sandbox as needed (for write operations).
sandboxWritePath :: FsConfig -> FilePath -> IO FilePath
sandboxWritePath cfg relPath = do
resolved <- sandboxPath cfg relPath
createDirectoryIfMissing True (takeDirectory resolved)
pure resolved
-- | Resolve @.@ and @..@ in a normalized absolute path
-- without touching the filesystem.
collapseDots :: FilePath -> FilePath
collapseDots = joinPath . reverse . foldl' step [] . splitDirectories
where
step acc "." = acc
step (_ : rest) ".." = rest
step acc ".." = acc -- at root, ignore
step acc x = x : acc
isFileHidden :: [Char] -> Bool
isFileHidden path = case path of
('.' : _) -> True
_ -> False
-- | 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.
isSymlink :: FilePath -> IO Bool
isSymlink p = do
r <- try (pathIsSymbolicLink p) :: IO (Either IOException Bool)
case r of
Right b -> pure b
Left _ -> pure False