packages feed

shikumi-tools-0.3.0.0: src/Shikumi/CodeExec/Interpreter.hs

{-# LANGUAGE RankNTypes #-}

-- | The sandbox as a swappable /value/ (EP-27).
--
-- A 'CodeInterpreter' takes a string of model-emitted code and returns either a
-- recoverable error message (fed back to the model) or the code's textual output.
-- It is captured in an 'Shikumi.Program.Embed' closure exactly as a
-- 'Shikumi.Tool.ToolRegistry' is — never added to the effect row — because the
-- @Embed@ body is fixed to the row @(LLM, Error ShikumiError)@. The hermetic
-- interpreters here are /pure/ (they ignore the effects and 'pure' their result), so
-- they fit inside any row.
--
-- == SECURITY POSTURE
--
-- Running model-emitted code is a genuine remote-code-execution risk. The hermetic
-- default 'restrictedInterpreter' is safe /by construction/: it parses a tiny DSL
-- and evaluates it purely — there is no syscall it can make, no filesystem, no
-- network, no unbounded loop primitive (a step/expression-size cap bounds runtime and
-- returns a typed error past it). The dangerous path is a real subprocess
-- interpreter (DSPy uses a @deno@/Pyodide sandbox); that is intentionally /not/
-- shipped here. A future, gated, non-CI @subprocessInterpreter@ (EP-27 M4) would have
-- to enforce, at minimum: no network; no host filesystem access beyond a single fresh
-- scratch dir; no environment inheritance; CPU/memory/wall-clock limits (killing the
-- child and surfacing a 'Shikumi.Error.Timeout'); and loud failure (any limit breach
-- becomes a 'Shikumi.Error.ShikumiError', never a silent empty result). The effect
-- row does not type-enforce purity: the built-in tool environments already perform
-- real IO inside the same row via 'Shikumi.Tool.Env.toolIO' and
-- 'Shikumi.Tool.Web.httpIO'. The hermeticity of the shipped interpreters is a
-- property of their construction, so any future subprocess interpreter must be
-- gated by explicit configuration and documentation, not assumed impossible by the
-- types.
--
-- == The restricted DSL
--
-- A single expression (optionally prefixed @result = @), built from: integer and
-- rational literals, unary minus, and @+ - * /@ with parentheses; string literals
-- with @\"@, @\\@, @\n@, and @\t@ escapes plus @++@ concatenation and the functions
-- @len@, @upper@, @lower@; and list literals @[a, b, …]@ with @sum@, @length@,
-- @concat@. The value of the expression is rendered to text as the output. A parse
-- error, an unknown identifier/function, a type error, division by zero, or
-- exceeding the step cap is returned as @Left \<message\>@.
module Shikumi.CodeExec.Interpreter
  ( -- * The interpreter value
    CodeInterpreter (..),

    -- * Hermetic implementations
    restrictedInterpreter,
    echoInterpreter,

    -- * The pure restricted evaluator (exposed for unit tests)
    runRestricted,
  )
where

import Control.Applicative ((<|>))
import Data.Char (isAlpha, isAlphaNum, isDigit, isSpace)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, (:>))
import Effectful.Error.Static (Error)
import Shikumi.Error (ShikumiError)
import Shikumi.LLM (LLM)
import Text.Read (readMaybe)

-- | A sandbox as a plain value: run a code string, get back either a recoverable
-- error message (@Left@, fed to the model) or the program's textual output
-- (@Right@). A genuine infrastructure failure (e.g. a real subprocess dying) would
-- be thrown as a 'ShikumiError' from inside 'runCode'; the hermetic interpreters
-- never do that.
newtype CodeInterpreter = CodeInterpreter
  { runCode :: forall es. (LLM :> es, Error ShikumiError :> es) => Text -> Eff es (Either Text Text)
  }

-- | The hermetic default: parse and evaluate the restricted DSL purely. No IO, no
-- network, no filesystem; a step cap bounds runtime.
restrictedInterpreter :: CodeInterpreter
restrictedInterpreter = CodeInterpreter (pure . runRestricted)

-- | Echo the code back as its output (a trivial stub for wiring tests).
echoInterpreter :: CodeInterpreter
echoInterpreter = CodeInterpreter (pure . Right)

-- ---------------------------------------------------------------------------
-- The restricted evaluator
-- ---------------------------------------------------------------------------

-- | Evaluate one restricted-DSL expression, returning the rendered result (@Right@)
-- or a recoverable error message (@Left@). Pure and total.
runRestricted :: Text -> Either Text Text
runRestricted src = do
  toks <- tokenize src
  expr <- parseProgram toks
  (v, _) <- eval stepCap expr
  pure (renderVal v)
  where
    stepCap = 10000

-- ---------------------------------------------------------------------------
-- Values
-- ---------------------------------------------------------------------------

data Val
  = VNum !Rational
  | VStr !Text
  | VList ![Val]
  deriving stock (Eq, Show)

renderVal :: Val -> Text
renderVal (VNum r)
  | denominator r == 1 = T.pack (show (numerator r))
  | otherwise = T.pack (show (fromRational r :: Double))
renderVal (VStr s) = s
renderVal (VList xs) = "[" <> T.intercalate ", " (map renderVal xs) <> "]"

-- ---------------------------------------------------------------------------
-- Tokenizer
-- ---------------------------------------------------------------------------

data Tok
  = TNum !Rational
  | TStr !Text
  | TIdent !Text
  | TKwResult
  | TPlus
  | TMinus
  | TStar
  | TSlash
  | TConcat
  | TLParen
  | TRParen
  | TLBrack
  | TRBrack
  | TComma
  | TEq
  deriving stock (Eq, Show)

tokenize :: Text -> Either Text [Tok]
tokenize = go . T.unpack
  where
    go [] = Right []
    go (c : cs)
      | isSpace c = go cs
      | isDigit c =
          let (numStr, rest) = span (\x -> isDigit x || x == '.') (c : cs)
           in case readNum numStr of
                Just r -> (TNum r :) <$> go rest
                Nothing -> Left ("bad number literal: " <> T.pack numStr)
      | c == '"' = lexString [] cs
      | isAlpha c || c == '_' =
          let (ident, rest) = span (\x -> isAlphaNum x || x == '_') (c : cs)
           in (identTok ident :) <$> go rest
      | c == '+' = case cs of
          ('+' : rest) -> (TConcat :) <$> go rest
          _ -> (TPlus :) <$> go cs
      | c == '-' = (TMinus :) <$> go cs
      | c == '*' = (TStar :) <$> go cs
      | c == '/' = (TSlash :) <$> go cs
      | c == '(' = (TLParen :) <$> go cs
      | c == ')' = (TRParen :) <$> go cs
      | c == '[' = (TLBrack :) <$> go cs
      | c == ']' = (TRBrack :) <$> go cs
      | c == ',' = (TComma :) <$> go cs
      | c == '=' = (TEq :) <$> go cs
      | otherwise = Left ("unexpected character: " <> T.singleton c)
    identTok "result" = TKwResult
    identTok s = TIdent (T.pack s)
    readNum :: String -> Maybe Rational
    readNum s = (fromInteger <$> (readMaybe s :: Maybe Integer)) <|> (toRational <$> (readMaybe s :: Maybe Double))
    lexString _ [] = Left "unterminated string literal"
    lexString acc ('"' : rest) = (TStr (T.pack (reverse acc)) :) <$> go rest
    lexString acc ('\\' : '"' : rest) = lexString ('"' : acc) rest
    lexString acc ('\\' : '\\' : rest) = lexString ('\\' : acc) rest
    lexString acc ('\\' : 'n' : rest) = lexString ('\n' : acc) rest
    lexString acc ('\\' : 't' : rest) = lexString ('\t' : acc) rest
    lexString _ ('\\' : esc : _) = Left ("unsupported string escape: \\" <> T.singleton esc)
    lexString acc (x : rest) = lexString (x : acc) rest

-- ---------------------------------------------------------------------------
-- Parser (recursive descent)
-- ---------------------------------------------------------------------------

data Op = Add | Sub | Mul | Div | Concat
  deriving stock (Eq, Show)

data Expr
  = ENum !Rational
  | EStr !Text
  | EList ![Expr]
  | EBin !Op !Expr !Expr
  | ECall !Text ![Expr]
  deriving stock (Eq, Show)

type P a = [Tok] -> Either Text (a, [Tok])

parseProgram :: [Tok] -> Either Text Expr
parseProgram toks0 = do
  let toks = case toks0 of
        (TKwResult : TEq : rest) -> rest
        _ -> toks0
  (e, rest) <- parseExpr toks
  case rest of
    [] -> Right e
    _ -> Left "unexpected trailing tokens"

parseExpr :: P Expr
parseExpr = parseAdd

parseAdd :: P Expr
parseAdd toks = do
  (l, r1) <- parseMul toks
  goAdd l r1
  where
    goAdd l (TPlus : ts) = parseMul ts >>= \(r, ts') -> goAdd (EBin Add l r) ts'
    goAdd l (TMinus : ts) = parseMul ts >>= \(r, ts') -> goAdd (EBin Sub l r) ts'
    goAdd l (TConcat : ts) = parseMul ts >>= \(r, ts') -> goAdd (EBin Concat l r) ts'
    goAdd l ts = Right (l, ts)

parseMul :: P Expr
parseMul toks = do
  (l, r1) <- parseFactor toks
  goMul l r1
  where
    goMul l (TStar : ts) = parseFactor ts >>= \(r, ts') -> goMul (EBin Mul l r) ts'
    goMul l (TSlash : ts) = parseFactor ts >>= \(r, ts') -> goMul (EBin Div l r) ts'
    goMul l ts = Right (l, ts)

parseFactor :: P Expr
parseFactor (TMinus : ts) = parseFactor ts >>= \(e, ts') -> Right (EBin Sub (ENum 0) e, ts')
parseFactor (TNum n : ts) = Right (ENum n, ts)
parseFactor (TStr s : ts) = Right (EStr s, ts)
parseFactor (TLParen : ts) = do
  (e, ts') <- parseExpr ts
  case ts' of
    (TRParen : ts'') -> Right (e, ts'')
    _ -> Left "expected closing )"
parseFactor (TLBrack : ts) = parseSeq TRBrack ts >>= \(es, ts') -> Right (EList es, ts')
parseFactor (TIdent name : TLParen : ts) = parseSeq TRParen ts >>= \(args, ts') -> Right (ECall name args, ts')
parseFactor (TIdent name : ts) = Right (ECall name [], ts)
parseFactor _ = Left "expected an expression"

-- | Parse a comma-separated sequence of expressions terminated by the given token.
parseSeq :: Tok -> P [Expr]
parseSeq close (t : ts) | t == close = Right ([], ts)
parseSeq close ts = do
  (e, ts') <- parseExpr ts
  case ts' of
    (TComma : ts'') -> parseSeq close ts'' >>= \(es, ts3) -> Right (e : es, ts3)
    (t : ts'') | t == close -> Right ([e], ts'')
    _ -> Left "expected , or closing bracket"

-- ---------------------------------------------------------------------------
-- Evaluator (with a step budget)
-- ---------------------------------------------------------------------------

eval :: Int -> Expr -> Either Text (Val, Int)
eval fuel _ | fuel <= 0 = Left "step cap exceeded"
eval fuel (ENum n) = Right (VNum n, fuel - 1)
eval fuel (EStr s) = Right (VStr s, fuel - 1)
eval fuel (EList es) = do
  (vs, f') <- evalList (fuel - 1) es
  Right (VList vs, f')
eval fuel (EBin op a b) = do
  (va, f1) <- eval (fuel - 1) a
  (vb, f2) <- eval f1 b
  v <- applyOp op va vb
  Right (v, f2)
eval fuel (ECall name args) = do
  (vs, f1) <- evalList (fuel - 1) args
  v <- applyFn name vs
  Right (v, f1)

evalList :: Int -> [Expr] -> Either Text ([Val], Int)
evalList fuel [] = Right ([], fuel)
evalList fuel (e : es) = do
  (v, f1) <- eval fuel e
  (vs, f2) <- evalList f1 es
  Right (v : vs, f2)

applyOp :: Op -> Val -> Val -> Either Text Val
applyOp Add (VNum a) (VNum b) = Right (VNum (a + b))
applyOp Sub (VNum a) (VNum b) = Right (VNum (a - b))
applyOp Mul (VNum a) (VNum b) = Right (VNum (a * b))
applyOp Div (VNum _) (VNum 0) = Left "division by zero"
applyOp Div (VNum a) (VNum b) = Right (VNum (a / b))
applyOp Concat (VStr a) (VStr b) = Right (VStr (a <> b))
applyOp Concat (VList a) (VList b) = Right (VList (a ++ b))
applyOp op _ _ = Left ("type error: operator " <> opName op <> " applied to incompatible values")

opName :: Op -> Text
opName = \case
  Add -> "+"
  Sub -> "-"
  Mul -> "*"
  Div -> "/"
  Concat -> "++"

applyFn :: Text -> [Val] -> Either Text Val
applyFn "len" [VStr s] = Right (VNum (fromIntegral (T.length s)))
applyFn "len" [VList xs] = Right (VNum (fromIntegral (length xs)))
applyFn "length" [VList xs] = Right (VNum (fromIntegral (length xs)))
applyFn "length" [VStr s] = Right (VNum (fromIntegral (T.length s)))
applyFn "upper" [VStr s] = Right (VStr (T.toUpper s))
applyFn "lower" [VStr s] = Right (VStr (T.toLower s))
applyFn "sum" [VList xs] = VNum . sum <$> traverse asNum xs
applyFn "concat" [VList xs] = concatVals xs
applyFn name [] = Left ("unknown identifier: " <> name)
applyFn name _ = Left ("unknown function or bad arguments: " <> name)

asNum :: Val -> Either Text Rational
asNum (VNum n) = Right n
asNum _ = Left "type error: expected a number"

concatVals :: [Val] -> Either Text Val
concatVals xs
  | all isStr xs = Right (VStr (T.concat [s | VStr s <- xs]))
  | all isList xs = Right (VList (concat [ys | VList ys <- xs]))
  | otherwise = Left "type error: concat expects a list of all-strings or all-lists"
  where
    isStr (VStr _) = True
    isStr _ = False
    isList (VList _) = True
    isList _ = False