packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/Judgement/Execute.hs

-- | Run one judgement over one captured snapshot.
--
-- The order is fixed and is the security contract: select evidence by the
-- definition's path filters; scan the selected bytes and every outgoing
-- instruction locally; record deterministic findings for definitions that
-- declare the scan; refuse to transmit anything the scanner flagged; apply
-- the project data policy; and only then send chunks, first to the gate when
-- one is permitted and then, for checks the gate did not clear, to the review.
-- The result is an observation; 'decideJudgement' turns it into a decision.
module Seihou.CLI.Judgement.Execute
  ( JudgementExecution (..),
    runJudgement,
  )
where

import Control.Monad (forM)
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.Maybe (isJust)
import Data.Text qualified as T
import Seihou.CLI.Judgement.Evidence
import Seihou.CLI.Judgement.Runtime (ChunkRequest (..), JudgementBackend)
import Seihou.CLI.Judgement.Scanner
import Seihou.Core.Judgement
import Seihou.Prelude

data JudgementExecution = JudgementExecution
  { policy :: !JudgementPolicy,
    -- | 'Nothing' when no permitted route is configured.
    backend :: !(Maybe JudgementBackend),
    -- | Whether the configured route sends material to a hosted service.
    remote :: !Bool,
    -- | The located scanner executable, if any.
    scanner :: !(Maybe FilePath)
  }
  deriving stock (Generic)

runJudgement :: JudgementExecution -> JudgementDefinition -> Snapshot -> IO JudgementObservation
runJudgement exec def snap = do
  let (selected, excluded, uncoveredSel) = selectForDefinition def snap
      limits = def ^. #limits
      checks = toList (def ^. #checks)
      declaresScan = SecretScan `elem` def ^. #deterministicChecks
      needsScan = declaresScan || exec ^. #remote
      instructionTexts = [("check '" <> c ^. #name <> "' instructions", def ^. #instructions <> "\n" <> c ^. #question <> "\n" <> foldMap id (c ^. #yesMeans) <> "\n" <> foldMap id (c ^. #noMeans)) | c <- checks]
      noChanges = null selected && null uncoveredSel
  if noChanges
    then pure (JudgementObservation (Coverage [] excluded []) [] [] [] True)
    else do
      scanned <-
        if not needsScan
          then pure (Right (ScanResult [] []))
          else case exec ^. #scanner of
            Nothing -> pure (Left "the secret scanner (gitleaks) was not found; install it or set SEIHOU_GITLEAKS")
            Just exe -> scanCaptured exe selected instructionTexts
      case scanned of
        Left err ->
          -- No scan, no transmission: every check ends as an error.
          pure (JudgementObservation (Coverage [] excluded uncoveredSel) [] [err] [CheckObservation (c ^. #name) [] ["not reviewed: " <> err] | c <- checks] False)
        Right scan -> do
          let deterministic = if declaresScan then scan ^. #findings else []
              flagged = flaggedPaths scan
              transmissible = filter ((`notElem` flagged) . (^. #path)) selected
              (chunks, overflow) = chunkEvidence limits transmissible
              coverage = Coverage (map (^. #path) transmissible) excluded (uncoveredSel <> overflow)
              index = evidenceIndex transmissible
              policy = exec ^. #policy
              gateOn = policy ^. #gatePermitted
              baseErrors c =
                [ "'" <> T.pack p <> "' was not sent: the transmission safeguard flagged it, and this judgement does not declare the secret scan"
                | not declaresScan,
                  p <- flagged
                ]
                  <> [ "the " <> name <> " were flagged by the transmission safeguard and were not sent"
                     | name <- scan ^. #flaggedTexts,
                       name == "check '" <> c ^. #name <> "' instructions"
                     ]
                  <> [ "the project data policy is local-only and the configured route sends material to a hosted service"
                     | exec ^. #remote && policy ^. #dataPolicy == LocalOnly
                     ]
                  <> ["no review route is configured; set one with seihou config set judgement.provider PROVIDER and seihou config set judgement.model MODEL" | not (isJust (exec ^. #backend))]
          observations <- forM checks $ \c -> do
            let errs = baseErrors c
                cpol = checkPolicyFor policy c
            if not (null deterministic)
              then pure (CheckObservation (c ^. #name) [] ["not reviewed: a deterministic finding stopped further transmission"])
              else
                if not (null errs)
                  then pure (CheckObservation (c ^. #name) [] errs)
                  else case exec ^. #backend of
                    Nothing -> pure (CheckObservation (c ^. #name) [] ["no permitted review route is configured"])
                    Just be -> do
                      cs <- forM chunks $ \ch -> do
                        let req = ChunkRequest (ch ^. #index) (ch ^. #text)
                            gateFn = if gateOn && cpol ^. #useGate then be ^. #gate else Nothing
                        gateObs <- forM gateFn $ \g -> either GateFailed GateAnswered <$> g def c req
                        let cleared = case gateObs of
                              Just (GateAnswered p) -> validProbability p && p <= cpol ^. #gate . #passAt
                              _ -> False
                        reviewObs <-
                          if cleared
                            then pure Nothing
                            else do
                              r <- (be ^. #review) def c req
                              pure (Just (r >>= validateReview limits c index))
                        pure (ChunkObservation (ch ^. #index) gateObs reviewObs)
                      pure (CheckObservation (c ^. #name) cs [])
          pure (JudgementObservation coverage deterministic [] observations False)