packages feed

langchain-hs-core-0.0.6.0: src/Langchain/Tool/Security.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

{- |
Module      : Langchain.Tool.Security
Description : Security, permission tiers, sandbox validation, and HITL hooks
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Provides tiered permission policies for tools (ReadOnly, WorkspaceWrite, Execution, Admin),
filesystem path sandboxing, sensitive credential filtering, dangerous command prevention,
and Human-in-the-Loop (HITL) approval hooks.
-}
module Langchain.Tool.Security
  ( -- * Permission Tiers
    ToolTier (..)

    -- * Security Policy
  , SecurityPolicy (..)
  , defaultSecurityPolicy
  , readOnlySecurityPolicy
  , workspaceSandboxPolicy
  , unrestrictedSecurityPolicy

    -- * Approval & HITL
  , ApprovalRequest (..)
  , ApprovalDecision (..)

    -- * Validation Functions
  , checkTierPermission
  , validateSandboxPath
  , isSensitivePath
  , validateCommandSafety

    -- * Tool Security Combinator
  , withSecurity
  ) where

import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (FromJSON, ToJSON, Value)
import Data.Char (toLower)
import Data.List (isInfixOf, isPrefixOf)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist)
import System.FilePath (isAbsolute, normalise, pathSeparator, takeDirectory, takeFileName, (</>))

import Langchain.Error (LangchainError, toolError)
import Langchain.Tool (Tool (..))

-- | Tier levels representing the risk and side-effects of tool execution.
data ToolTier
  = -- | Safe, observational queries (find, grep, read, list, status)
    TierReadOnly
  | -- | Modifies files within workspace sandbox (write, edit, rollback)
    TierWorkspaceWrite
  | -- | Spawns system processes and shell commands
    TierExecution
  | -- | Unrestricted or system-level modifications
    TierAdmin
  deriving (Eq, Ord, Show, Read, Enum, Bounded, Generic, ToJSON, FromJSON)

-- | Human-in-the-Loop approval request structure.
data ApprovalRequest = ApprovalRequest
  { reqToolName :: !Text
  , reqTier :: !ToolTier
  , reqArguments :: !Value
  , reqDescription :: !Text
  }
  deriving (Eq, Show, Generic, ToJSON, FromJSON)

-- | Decision result from an approval hook.
data ApprovalDecision
  = Approved
  | Denied !Text
  deriving (Eq, Show, Generic, ToJSON, FromJSON)

-- | Security policy controlling tool authorization and sandboxing bounds.
data SecurityPolicy = SecurityPolicy
  { policyAllowedTiers :: !(Set.Set ToolTier)
  -- ^ Set of authorized tiers
  , policyWorkspaceRoot :: !(Maybe FilePath)
  -- ^ If set, restricts file operations to within this directory tree
  , policySensitivePatterns :: ![Text]
  -- ^ File names or substrings blocked from reading/writing (e.g. .env, id_rsa, keys)
  , policyCommandWhitelist :: !(Maybe [Text])
  -- ^ Optional strict whitelist of allowed command binaries
  , policyCommandBlacklist :: ![Text]
  -- ^ Blocked dangerous command substrings (e.g. rm -rf /, fork bombs)
  , policyApprovalHook :: !(Maybe (ApprovalRequest -> IO ApprovalDecision))
  -- ^ Optional Human-in-the-Loop approval callback for sensitive actions
  }
  deriving (Generic)

{- | Default standard security policy:

* Allows 'TierReadOnly', 'TierWorkspaceWrite', and 'TierExecution'.
* Protects sensitive secret files (.env, private keys, credential files).
* Blocks destructive shell command patterns.
-}
defaultSecurityPolicy :: SecurityPolicy
defaultSecurityPolicy =
  SecurityPolicy
    { policyAllowedTiers = Set.fromList [TierReadOnly, TierWorkspaceWrite, TierExecution]
    , policyWorkspaceRoot = Nothing
    , policySensitivePatterns =
        [ ".env"
        , "id_rsa"
        , "id_ed25519"
        , "id_ecdsa"
        , ".aws/credentials"
        , ".gemini_api_key"
        , "shadow"
        , "master.passwd"
        ]
    , policyCommandWhitelist = Nothing
    , policyCommandBlacklist =
        [ "rm -rf /"
        , "rm -fr /"
        , ":(){ :|:& };:"
        , "mkfs"
        , "dd if=/dev/zero"
        , "chmod -R 777 /"
        , "chown -R"
        , "> /dev/sda"
        ]
    , policyApprovalHook = Nothing
    }

-- | Restrictive read-only security policy (prevents any state mutations).
readOnlySecurityPolicy :: SecurityPolicy
readOnlySecurityPolicy =
  defaultSecurityPolicy
    { policyAllowedTiers = Set.singleton TierReadOnly
    }

-- | Sandbox security policy restricting operations strictly to a workspace directory.
workspaceSandboxPolicy :: FilePath -> SecurityPolicy
workspaceSandboxPolicy root =
  defaultSecurityPolicy
    { policyWorkspaceRoot = Just root
    }

-- | Unrestricted security policy allowing all tiers and bypassing sensitive file filters.
unrestrictedSecurityPolicy :: SecurityPolicy
unrestrictedSecurityPolicy =
  SecurityPolicy
    { policyAllowedTiers = Set.fromList [TierReadOnly, TierWorkspaceWrite, TierExecution, TierAdmin]
    , policyWorkspaceRoot = Nothing
    , policySensitivePatterns = []
    , policyCommandWhitelist = Nothing
    , policyCommandBlacklist = []
    , policyApprovalHook = Nothing
    }

-- | Check whether a tool tier is permitted under the policy.
checkTierPermission :: SecurityPolicy -> ToolTier -> Either LangchainError ()
checkTierPermission SecurityPolicy {..} tier
  | tier `Set.member` policyAllowedTiers = Right ()
  | otherwise =
      Left $
        toolError
          ( "Permission denied: Tool requires "
              <> T.pack (show tier)
              <> " authorization, which is not granted by the current security policy."
          )
          Nothing
          Nothing

-- | Check if a path refers to a sensitive file (keys, credentials, secrets).
isSensitivePath :: SecurityPolicy -> FilePath -> Bool
isSensitivePath SecurityPolicy {..} path =
  let lowerPath = map toLower (normalise path)
   in any (\pat -> T.unpack (T.toLower pat) `isInfixOf` lowerPath) policySensitivePatterns

-- | Validate that a file path remains safely within the configured workspace sandbox.
validateSandboxPath ::
  MonadIO m =>
  SecurityPolicy ->
  FilePath ->
  m (Either LangchainError FilePath)
validateSandboxPath SecurityPolicy {..} rawPath = liftIO $ do
  -- 1. Check sensitive path filter
  if isSensitivePath
    ( SecurityPolicy
        policyAllowedTiers
        policyWorkspaceRoot
        policySensitivePatterns
        policyCommandWhitelist
        policyCommandBlacklist
        policyApprovalHook
    )
    rawPath
    then
      pure $
        Left $
          toolError
            ("Access denied: Path matches sensitive/secret file policy: " <> T.pack rawPath)
            Nothing
            Nothing
    else case policyWorkspaceRoot of
      Nothing -> pure $ Right (normalise rawPath)
      Just root -> do
        canonRoot <- canonicalizePath root
        let targetPath =
              if isAbsolute rawPath
                then rawPath
                else canonRoot </> rawPath
        canonTarget <- canonicalizeAncestor targetPath
        let prefix = canonRoot ++ [pathSeparator]
            isInside = canonTarget == canonRoot || prefix `isPrefixOf` canonTarget
        if isInside
          then pure $ Right canonTarget
          else
            pure $
              Left $
                toolError
                  ( "Sandbox violation: Path '"
                      <> T.pack rawPath
                      <> "' escapes the workspace root '"
                      <> T.pack canonRoot
                      <> "'."
                  )
                  Nothing
                  Nothing
  where
    canonicalizeAncestor :: FilePath -> IO FilePath
    canonicalizeAncestor p = do
      ex <- doesFileExist p
      dx <- doesDirectoryExist p
      if ex || dx
        then canonicalizePath p
        else do
          let parent = takeDirectory p
          if parent == p
            then canonicalizePath p
            else do
              canonParent <- canonicalizeAncestor parent
              pure (canonParent </> takeFileName p)

-- | Validate that a shell command does not contain dangerous destructive patterns.
validateCommandSafety :: SecurityPolicy -> Text -> Either LangchainError ()
validateCommandSafety SecurityPolicy {..} cmd = do
  -- Check blacklist
  let lowerCmd = T.toLower cmd
  mapM_
    ( \bl ->
        if bl `T.isInfixOf` lowerCmd
          then
            Left $
              toolError
                ("Command blocked by security policy (prohibited pattern: '" <> bl <> "')")
                Nothing
                Nothing
          else Right ()
    )
    policyCommandBlacklist

  -- Check whitelist if present
  case policyCommandWhitelist of
    Nothing -> Right ()
    Just wl ->
      let firstWord = case T.words cmd of
            (w : _) -> w
            [] -> ""
       in if firstWord `elem` wl
            then Right ()
            else
              Left $
                toolError
                  ("Command '" <> firstWord <> "' is not in the authorized command whitelist.")
                  Nothing
                  Nothing

-- | Wrap a 'Tool m' with permission tier enforcement, sandboxing, and optional HITL approval.
withSecurity ::
  MonadIO m =>
  ToolTier ->
  SecurityPolicy ->
  Tool m ->
  Tool m
withSecurity tier policy tool@Tool {..} =
  tool
    { toolExecute = \args -> do
        case checkTierPermission policy tier of
          Left err -> pure $ Left err
          Right () -> do
            -- Check HITL approval if hook is defined
            mApproval <- case policyApprovalHook policy of
              Nothing -> pure Approved
              Just hook -> liftIO $ hook (ApprovalRequest toolName tier args toolDescription)
            case mApproval of
              Denied reason ->
                pure $
                  Left $
                    toolError ("Execution rejected by approval policy: " <> reason) (Just toolName) Nothing
              Approved -> toolExecute args
    }