packages feed

praxo-core-0.3.0: src/Praxo/Plugin.hs

module Praxo.Plugin (parseManifest, validateManifest) where

import Control.Monad (unless)
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import qualified Data.Set as Set
import Data.Ratio (denominator)
import Praxo.Json

parseManifest :: String -> Either String Json
parseManifest raw = do
    manifest <- parseJson raw
    validateManifest manifest
    pure manifest

validateManifest :: Json -> Either String ()
validateManifest manifest = do
    strictObject topKeys manifest
    unless (all (`elem` map fst (asObject manifest)) requiredKeys) (Left "PLUGIN_MISSING_FIELD")
    unless (field "schemaVersion" manifest == JNumber 1) (Left "PLUGIN_SCHEMA_VERSION")
    identifier <- requiredString "id" manifest
    let labels = split '.' identifier
    unless (length labels >= 2 && all validLabel labels) (Left "PLUGIN_INVALID_ID")
    version <- requiredString "version" manifest
    unless (validVersion version) (Left "PLUGIN_INVALID_VERSION")
    kind <- requiredString "kind" manifest
    unless (kind `elem` [name ++ ".provider.v1" | name <- ["tool", "model", "policy", "memory", "sandbox"]]) (Left "PLUGIN_INVALID_KIND")
    protocol <- requiredString "protocol" manifest
    unless (protocol == "jsonrpc-2") (Left "PLUGIN_INVALID_PROTOCOL")
    let entrypoint = field "entrypoint" manifest
    strictObject ["type", "command", "module", "image", "args", "dir"] entrypoint
    launchType <- requiredString "type" entrypoint
    target <- case lookup launchType [("process", "command"), ("wasm", "module"), ("container", "image")] of
        Nothing -> Left "PLUGIN_INVALID_ENTRYPOINT"
        Just key -> requiredString key entrypoint
    unless (not (null target)) (Left "PLUGIN_INVALID_ENTRYPOINT")
    mapM_ (optionalString entrypoint) ["command", "module", "image", "dir"]
    optionalStrings entrypoint "args"
    isolation <- requiredString "isolation" manifest
    unless (isolation == launchType) (Left "PLUGIN_INVALID_ISOLATION")
    capabilities <- strings (field "capabilities" manifest)
    unless (all (not . null) capabilities && length capabilities == Set.size (Set.fromList capabilities)) (Left "PLUGIN_INVALID_CAPABILITIES")
    permissions (field "permissions" manifest)
    nonnegative ["memoryMB", "cpuMillis", "timeoutMs", "maxInflight"] (field "limits" manifest)
    let health = field "health" manifest
    strictObject ["method", "intervalMs"] health
    optionalString health "method"
    interval <- optionalInteger health "intervalMs"
    unless (interval >= 0 && (interval == 0 || not (null (asString (field "method" health))))) (Left "PLUGIN_INVALID_HEALTH")
    case field "signature" manifest of
        JNull -> pure ()
        signature -> do
            strictObject ["algorithm", "keyId"] signature
            _ <- requiredString "algorithm" signature
            _ <- requiredString "keyId" signature
            pure ()
    optionalString manifest "sbom"
    let sbom = asString (field "sbom" manifest)
    unless (null sbom || relativePath sbom) (Left "PLUGIN_INVALID_SBOM")
  where
    requiredKeys = ["schemaVersion", "id", "version", "kind", "entrypoint", "protocol", "capabilities", "permissions", "limits", "health", "isolation"]
    topKeys = requiredKeys ++ ["signature", "sbom"]

strictObject :: [String] -> Json -> Either String ()
strictObject allowed (JObject fields) = do
    unless (all ((`elem` allowed) . fst) fields) (Left "PLUGIN_UNKNOWN_FIELD")
    unless (length fields == Set.size (Set.fromList (map fst fields))) (Left "PLUGIN_DUPLICATE_FIELD")
strictObject _ _ = Left "PLUGIN_EXPECTED_OBJECT"

requiredString :: String -> Json -> Either String String
requiredString key object = case field key object of
    JString value | not (null value) -> Right value
    _ -> Left ("PLUGIN_INVALID_STRING:" ++ key)

optionalString :: Json -> String -> Either String ()
optionalString object key
    | key `notElem` map fst (asObject object) = Right ()
    | otherwise = case field key object of
        JString _ -> Right ()
        _ -> Left ("PLUGIN_INVALID_STRING:" ++ key)

strings :: Json -> Either String [String]
strings (JArray values) = mapM stringItem values
  where
    stringItem (JString text) = Right text
    stringItem _ = Left "PLUGIN_EXPECTED_STRING"
strings _ = Left "PLUGIN_EXPECTED_ARRAY"

optionalStrings :: Json -> String -> Either String ()
optionalStrings object key
    | key `notElem` map fst (asObject object) = Right ()
    | otherwise = strings (field key object) >> pure ()

optionalInteger :: Json -> String -> Either String Integer
optionalInteger object key
    | key `notElem` map fst (asObject object) = Right 0
    | otherwise = case field key object of
        JNumber value | denominator value == 1 -> Right (round value)
        _ -> Left ("PLUGIN_INVALID_INTEGER:" ++ key)

nonnegative :: [String] -> Json -> Either String ()
nonnegative keys object = do
    strictObject keys object
    values <- mapM (optionalInteger object) keys
    unless (all (>= 0) values) (Left "PLUGIN_NEGATIVE_LIMIT")

permissions :: Json -> Either String ()
permissions object = do
    strictObject ["filesystem", "network", "env"] object
    optionalStrings object "env"
    env <- if field "env" object == JNull then Right [] else strings (field "env" object)
    unless (all validEnv env) (Left "PLUGIN_INVALID_ENV")
    entries "filesystem" object $ \entry -> do
        strictObject ["path", "mode"] entry
        path <- requiredString "path" entry
        mode <- requiredString "mode" entry
        unless (mode `elem` ["read", "write", "readwrite"] && ".." `notElem` split '/' (slashes path)) (Left "PLUGIN_INVALID_FILESYSTEM")
    entries "network" object $ \entry -> do
        strictObject ["host", "port"] entry
        _ <- requiredString "host" entry
        port <- optionalInteger entry "port"
        unless (port >= 1 && port <= 65535) (Left "PLUGIN_INVALID_NETWORK")
  where
    validEnv (first:rest) = (isAsciiLower first || isAsciiUpper first || first == '_') && all envChar rest
    validEnv [] = False
    envChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_'

entries :: String -> Json -> (Json -> Either String ()) -> Either String ()
entries key object check
    | key `notElem` map fst (asObject object) = Right ()
    | otherwise = case field key object of
        JArray values -> mapM_ check values
        _ -> Left "PLUGIN_EXPECTED_ARRAY"

split :: Char -> String -> [String]
split separator text = case break (== separator) text of
    (first, []) -> [first]
    (first, _:rest) -> first : split separator rest

validLabel :: String -> Bool
validLabel [] = False
validLabel label@(first:_) = alphaNumber first && alphaNumber (last label) && all (\c -> alphaNumber c || c == '-') label
  where alphaNumber c = isAsciiLower c || isDigit c

validVersion :: String -> Bool
validVersion text =
    let (baseAndPre, metadata) = break (== '+') text
        (base, prerelease) = break (== '-') baseAndPre
        numeric [] = False
        numeric part@(first:_) = all isDigit part && (part == "0" || first /= '0')
        suffix [] = True
        suffix (_:rest) = not (null rest) && all (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ".-") rest
    in length (split '.' base) == 3 && all numeric (split '.' base) && suffix prerelease && suffix metadata

slashes :: String -> String
slashes = map (\c -> if c == '\\' then '/' else c)

relativePath :: String -> Bool
relativePath [] = False
relativePath path@(first:_) = first /= '/' && first /= '\\' && ':' `notElem` path && ".." `notElem` split '/' (slashes path)