packages feed

shikumi-tools-0.3.0.0: src/Shikumi/Tool/Env.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE RankNTypes #-}

-- | Execution-environment operations for built-in work tools.
--
-- Filesystem and process-backed tools should depend on this record instead of
-- calling the host operating system directly. A future sandbox can provide a
-- different 'ToolEnv' value while the tool definitions stay unchanged.
--
-- Security posture: 'localToolEnv' is deliberately non-hermetic. It has full
-- access to the host filesystem paths and inherited environment visible to the
-- current process, and it can execute arbitrary shell commands with the current
-- user's privileges. Its filesystem and process IO is performed inside the
-- @(LLM, Error ShikumiError)@ row via 'unsafeEff_'. Supply a different 'ToolEnv'
-- to confine tools; this module is the sandboxing seam, not the sandbox.
module Shikumi.Tool.Env
  ( Path,
    EnvRow,
    ExecRequest (..),
    ExecResult (..),
    FileStat (..),
    DirEntry (..),
    ToolEnv (..),
    localToolEnv,
  )
where

import Control.Exception (IOException, try)
import Control.Lens ((^.))
import Data.Aeson (ToJSON)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, (:>))
import Effectful.Dispatch.Static (unsafeEff_)
import Effectful.Error.Static (Error, throwError)
import GHC.Generics (Generic)
import Shikumi.Error (ShikumiError (..))
import Shikumi.LLM (LLM)
import System.Directory qualified as Dir
import System.Exit (ExitCode (..))
import System.Process qualified as Proc
import System.Timeout qualified as Timeout

type Path = Text

type EnvRow es = (LLM :> es, Error ShikumiError :> es)

data ExecRequest = ExecRequest
  { command :: !Text,
    cwd :: !(Maybe Path),
    stdin :: !(Maybe Text),
    timeoutMs :: !(Maybe Int)
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data ExecResult = ExecResult
  { exitCode :: !Int,
    stdout :: !Text,
    stderr :: !Text
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data FileStat = FileStat
  { isFile :: !Bool,
    isDir :: !Bool,
    size :: !Integer
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data DirEntry = DirEntry
  { name :: !Text,
    isDir :: !Bool,
    isSymlink :: !Bool
  }
  deriving stock (Generic, Show, Eq)
  deriving anyclass (ToJSON)

data ToolEnv = ToolEnv
  { envExec :: forall es. (EnvRow es) => ExecRequest -> Eff es ExecResult,
    envReadFile :: forall es. (EnvRow es) => Path -> Eff es ByteString,
    envWriteFile :: forall es. (EnvRow es) => Path -> ByteString -> Eff es (),
    envStat :: forall es. (EnvRow es) => Path -> Eff es (Maybe FileStat),
    envReaddir :: forall es. (EnvRow es) => Path -> Eff es [DirEntry],
    envExists :: forall es. (EnvRow es) => Path -> Eff es Bool,
    envMkdir :: forall es. (EnvRow es) => Path -> Eff es (),
    envRm :: forall es. (EnvRow es) => Path -> Eff es (),
    envCwd :: forall es. (EnvRow es) => Eff es Path
  }

localToolEnv :: ToolEnv
localToolEnv =
  ToolEnv
    { envExec = localExec,
      envReadFile = \path -> toolIO "readFile" (BS.readFile (T.unpack path)),
      envWriteFile = \path bytes -> toolIO "writeFile" (BS.writeFile (T.unpack path) bytes),
      envStat = localStat,
      envReaddir = localReaddir,
      envExists = \path -> toolIO "exists" (Dir.doesPathExist (T.unpack path)),
      envMkdir = \path -> toolIO "mkdir" (Dir.createDirectoryIfMissing True (T.unpack path)),
      envRm = localRm,
      envCwd = T.pack <$> toolIO "cwd" Dir.getCurrentDirectory
    }

localExec :: (EnvRow es) => ExecRequest -> Eff es ExecResult
localExec req = do
  let effectiveTimeoutMs = effectiveTimeout (req ^. #timeoutMs)
  result <-
    toolIO "exec" $
      Timeout.timeout (effectiveTimeoutMs * 1000) $
        Proc.readCreateProcessWithExitCode process stdinText
  case result of
    Nothing ->
      throwError $
        Timeout
          ("tool env: exec timed out after " <> T.pack (show effectiveTimeoutMs) <> "ms")
    Just (exit, out, err) ->
      pure
        ExecResult
          { exitCode = exitCodeInt exit,
            stdout = T.pack out,
            stderr = T.pack err
          }
  where
    process =
      (Proc.proc "bash" ["-c", T.unpack (req ^. #command)])
        { Proc.cwd = T.unpack <$> (req ^. #cwd)
        }
    stdinText = maybe "" T.unpack (req ^. #stdin)

localStat :: (EnvRow es) => Path -> Eff es (Maybe FileStat)
localStat path = toolIO "stat" $ do
  let fp = T.unpack path
  file <- Dir.doesFileExist fp
  dir <- Dir.doesDirectoryExist fp
  if file
    then do
      size <- Dir.getFileSize fp
      pure (Just FileStat {isFile = True, isDir = False, size})
    else
      if dir
        then pure (Just FileStat {isFile = False, isDir = True, size = 0})
        else pure Nothing

localReaddir :: (EnvRow es) => Path -> Eff es [DirEntry]
localReaddir path = toolIO "readdir" $ do
  let dir = T.unpack path
  names <- Dir.listDirectory dir
  traverse
    ( \entry -> do
        let child = dir <> "/" <> entry
        isSymlink <- Dir.pathIsSymbolicLink child
        isDir <- Dir.doesDirectoryExist child
        pure DirEntry {name = T.pack entry, isDir, isSymlink}
    )
    names

localRm :: (EnvRow es) => Path -> Eff es ()
localRm path = toolIO "rm" $ do
  let fp = T.unpack path
  file <- Dir.doesFileExist fp
  dir <- Dir.doesDirectoryExist fp
  if file
    then Dir.removeFile fp
    else
      if dir
        then Dir.removeDirectory fp
        else pure ()

toolIO :: (EnvRow es) => Text -> IO a -> Eff es a
toolIO label action = do
  result <- unsafeEff_ (try action)
  case result of
    Right value -> pure value
    Left (err :: IOException) ->
      throwError (ProviderFailure ("tool env: " <> label <> ": " <> T.pack (show err)))

-- | Clamp a model-supplied timeout into [1 ms, 'maxExecTimeoutMs']. Unclamped,
-- a non-positive value disables the timeout entirely and a huge value can
-- overflow the microsecond multiplication in 'localExec'.
effectiveTimeout :: Maybe Int -> Int
effectiveTimeout = max 1 . min maxExecTimeoutMs . maybe defaultExecTimeoutMs id

defaultExecTimeoutMs :: Int
defaultExecTimeoutMs = 60000

maxExecTimeoutMs :: Int
maxExecTimeoutMs = 600000

exitCodeInt :: ExitCode -> Int
exitCodeInt ExitSuccess = 0
exitCodeInt (ExitFailure n) = n