baikai-0.6.0.0: test/AgentSpec.hs
module AgentSpec (tests) where
import Baikai.Agent
import Baikai.Prelude
import Data.Text qualified as Text
import System.Exit (ExitCode (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Baikai.Agent"
[ requestDefaultTest,
canonicalRenderingTest,
ceilingAcceptanceTest,
ceilingRefusalTest,
multipleViolationTest,
emptyAllowedProvidersTest,
providerArgsCeilingTest,
toolGrantCeilingTest,
impliedGrantsTest,
timeoutCeilingTest,
outputLimitCeilingTest,
violationRenderingTest,
capturedOutputTest,
failureRenderingTest,
resultConstructorTest
]
-- | A request built by the smart constructor must default every
-- optional field to the least-authority value. Asserting all of them
-- means a later plan adding a field has to decide its default
-- consciously rather than inherit an accident.
requestDefaultTest :: TestTree
requestDefaultTest =
testCase "agentRunRequest defaults to read-only, inherited output, and no limits" $ do
let req = agentRunRequest AgentClaude "/tmp/work" "do the thing"
req ^. #provider @?= AgentClaude
req ^. #prompt @?= "do the thing"
req ^. #workingDir @?= "/tmp/work"
req ^. #modelId @?= Nothing
req ^. #effort @?= Nothing
req ^. #extraDirs @?= []
req ^. #safety . #capability @?= AgentReadOnly
req ^. #safety . #allowedTools @?= []
req ^. #safety . #providerArgs @?= []
req ^. #timeout @?= Nothing
req ^. #output @?= InheritOutput
req ^. #outputFormat @?= TextFormat
req ^. #outputLimit @?= Nothing
req ^. #envRequires @?= []
canonicalRenderingTest :: TestTree
canonicalRenderingTest =
testCase "provider, capability, and output-mode names round-trip exactly" $ do
renderAgentProvider AgentClaude @?= "claude"
renderAgentProvider AgentCodex @?= "codex"
parseAgentProvider "claude" @?= Just AgentClaude
parseAgentProvider "codex" @?= Just AgentCodex
parseAgentProvider "Claude" @?= Nothing
parseAgentProvider "" @?= Nothing
renderAgentCapability AgentReadOnly @?= "read-only"
renderAgentCapability AgentEditWorkspace @?= "edit-workspace"
renderAgentCapability AgentFullAccess @?= "full-access"
parseAgentCapability "read-only" @?= Just AgentReadOnly
parseAgentCapability "edit-workspace" @?= Just AgentEditWorkspace
parseAgentCapability "full-access" @?= Just AgentFullAccess
parseAgentCapability "Read-Only" @?= Nothing
parseAgentCapability "readonly" @?= Nothing
renderAgentOutputMode InheritOutput @?= "inherit"
renderAgentOutputMode CaptureOutput @?= "capture"
renderAgentOutputMode TeeOutput @?= "tee"
parseAgentOutputMode "inherit" @?= Just InheritOutput
parseAgentOutputMode "capture" @?= Just CaptureOutput
parseAgentOutputMode "tee" @?= Just TeeOutput
parseAgentOutputMode "Tee" @?= Nothing
renderAgentOutputFormat TextFormat @?= "text"
renderAgentOutputFormat JsonFormat @?= "json"
parseAgentOutputFormat "text" @?= Just TextFormat
parseAgentOutputFormat "json" @?= Just JsonFormat
parseAgentOutputFormat "JSON" @?= Nothing
parseAgentOutputFormat "stream-json" @?= Nothing
-- | A request carrying a per-stream output limit.
--
-- 'agentRunRequest' defaults 'outputLimit' to 'Nothing', which means
-- \"capture without bound\", and the default ceiling's
-- 'defaultMaxOutputLimit' refuses exactly that. Every case below that is
-- not itself about the output limit starts from this helper, so the
-- violation it asserts is the only one in the list. Jobs resolved
-- through @baikai-agent@ never hit this, because that layer's own
-- default supplies a finite limit.
bounded :: AgentRunRequest -> AgentRunRequest
bounded request = request & #outputLimit .~ Just 4096
-- | Accepting a request must return it byte-identical. The equality
-- assertion against the original value is what proves no clamping
-- happened.
ceilingAcceptanceTest :: TestTree
ceilingAcceptanceTest =
testCase "the default ceiling accepts read-only and edit-workspace unchanged" $ do
let readOnly = bounded (agentRunRequest AgentClaude "/tmp/work" "look around")
editing =
readOnly
& #safety
.~ (agentSafety AgentEditWorkspace & #allowedTools .~ ["Read", "Edit"])
& #timeout
.~ Just 600
& #outputLimit
.~ Just 1024
applyAgentCeiling defaultAgentCeiling readOnly @?= Right readOnly
-- Grants the capability already implies, a timeout under an
-- unlimited maximum, and a limit under the default maximum all pass
-- through untouched.
applyAgentCeiling defaultAgentCeiling editing @?= Right editing
ceilingRefusalTest :: TestTree
ceilingRefusalTest =
testCase "the ceiling refuses with the exact violation for each closed channel" $ do
let base = bounded (agentRunRequest AgentClaude "/tmp/work" "rewrite everything")
greedy = base & #safety .~ agentSafety AgentFullAccess
rawArgs =
base
& #safety
. #providerArgs
.~ ["--dangerously-skip-permissions", "--verbose"]
claudeOnly = defaultAgentCeiling & #allowedProviders .~ [AgentClaude]
codexRequest = bounded (agentRunRequest AgentCodex "/tmp/work" "rewrite everything")
applyAgentCeiling defaultAgentCeiling greedy
@?= Left [CapabilityExceeded AgentFullAccess AgentEditWorkspace]
applyAgentCeiling defaultAgentCeiling rawArgs
@?= Left
[ProviderArgsForbidden ["--dangerously-skip-permissions", "--verbose"]]
applyAgentCeiling claudeOnly codexRequest
@?= Left [ProviderForbidden AgentCodex [AgentClaude]]
-- | Every violation is reported, not just the first one, so an
-- operator fixing a job description sees all of them in one run.
multipleViolationTest :: TestTree
multipleViolationTest =
testCase "a request that breaks three rules reports all three violations" $ do
let restrictive =
defaultAgentCeiling
& #maxCapability
.~ AgentReadOnly
& #allowProviderArgs
.~ False
& #allowedProviders
.~ [AgentClaude]
req =
bounded (agentRunRequest AgentCodex "/tmp/work" "rewrite everything")
& #safety
.~ ( agentSafety AgentFullAccess
& #providerArgs
.~ ["--dangerously-bypass-approvals-and-sandbox"]
)
applyAgentCeiling restrictive req
@?= Left
[ ProviderForbidden AgentCodex [AgentClaude],
CapabilityExceeded AgentFullAccess AgentReadOnly,
ProviderArgsForbidden ["--dangerously-bypass-approvals-and-sandbox"]
]
-- | An empty permitted-provider list means no provider is permitted.
-- The opposite reading would be a security hole, so it is pinned.
emptyAllowedProvidersTest :: TestTree
emptyAllowedProvidersTest =
testCase "an empty allowedProviders list permits no provider" $ do
let closed = defaultAgentCeiling & #allowedProviders .~ []
claudeRequest = bounded (agentRunRequest AgentClaude "/tmp/work" "hello")
codexRequest = bounded (agentRunRequest AgentCodex "/tmp/work" "hello")
applyAgentCeiling closed claudeRequest
@?= Left [ProviderForbidden AgentClaude []]
applyAgentCeiling closed codexRequest
@?= Left [ProviderForbidden AgentCodex []]
providerArgsCeilingTest :: TestTree
providerArgsCeilingTest =
testCase "raw provider arguments pass only when the operator opens the channel" $ do
let req =
bounded (agentRunRequest AgentClaude "/tmp/work" "hello")
& #safety
. #providerArgs
.~ ["--some-vendor-flag"]
permissive = defaultAgentCeiling & #allowProviderArgs .~ True
applyAgentCeiling defaultAgentCeiling req
@?= Left [ProviderArgsForbidden ["--some-vendor-flag"]]
applyAgentCeiling permissive req @?= Right req
-- | A tool grant is authority, so the capability decides which grants
-- need no operator involvement and the operator's allow-list supplies
-- the rest. @Bash@ is in neither implied set, which is the whole point
-- of the finding this pins: a repository file granting itself shell
-- access under @edit-workspace@ must be refused.
toolGrantCeilingTest :: TestTree
toolGrantCeilingTest =
testCase "a tool grant needs the capability to imply it or the operator to grant it" $ do
let granting names =
bounded (agentRunRequest AgentClaude "/tmp/work" "look around")
& #safety
.~ (agentSafety AgentEditWorkspace & #allowedTools .~ names)
bash = granting ["Bash"]
applyAgentCeiling defaultAgentCeiling bash
@?= Left [ToolGrantForbidden ["Bash"] AgentEditWorkspace]
applyAgentCeiling (defaultAgentCeiling & #allowedTools .~ ["Bash"]) bash @?= Right bash
applyAgentCeiling (defaultAgentCeiling & #maxCapability .~ AgentFullAccess) bash
@?= Right bash
-- Matching is exact on the whole string. A pattern-scoped grant is a
-- different grant, so granting the bare name does not permit it and
-- an operator who wants it writes it out.
let scoped = granting ["Bash(git *)"]
applyAgentCeiling (defaultAgentCeiling & #allowedTools .~ ["Bash"]) scoped
@?= Left [ToolGrantForbidden ["Bash(git *)"] AgentEditWorkspace]
-- Grants the capability already implies need no operator at all,
-- and only the forbidden ones are named in the refusal.
applyAgentCeiling defaultAgentCeiling (granting ["Read", "Write", "Bash", "WebFetch"])
@?= Left [ToolGrantForbidden ["Bash", "WebFetch"] AgentEditWorkspace]
-- | The implied grant lists are a security boundary, so they are pinned
-- name by name rather than by a property. A name added here widens every
-- ceiling in existence, which should require editing this test.
impliedGrantsTest :: TestTree
impliedGrantsTest =
testCase "each capability implies exactly the documented grants" $ do
toolGrantsImpliedBy AgentReadOnly
@?= Just ["Read", "Glob", "Grep", "NotebookRead", "TodoWrite"]
toolGrantsImpliedBy AgentEditWorkspace
@?= Just
[ "Read",
"Glob",
"Grep",
"NotebookRead",
"TodoWrite",
"Edit",
"MultiEdit",
"Write",
"NotebookEdit"
]
toolGrantsImpliedBy AgentFullAccess @?= Nothing
-- | A finite maximum bounds a requested timeout and also refuses a job
-- that requests none, because a maximum an operator can defeat by
-- omitting the setting is not a maximum.
timeoutCeilingTest :: TestTree
timeoutCeilingTest =
testCase "a finite max-timeout refuses a longer run and an untimed one" $ do
let twoHours = defaultAgentCeiling & #maxTimeout .~ Just 7200
asking limit = bounded (agentRunRequest AgentClaude "/tmp/work" "work") & #timeout .~ limit
applyAgentCeiling twoHours (asking (Just 3600)) @?= Right (asking (Just 3600))
applyAgentCeiling twoHours (asking (Just 7200)) @?= Right (asking (Just 7200))
applyAgentCeiling twoHours (asking (Just 10800))
@?= Left [TimeoutExceeded (Just 10800) 7200]
applyAgentCeiling twoHours (asking Nothing) @?= Left [TimeoutExceeded Nothing 7200]
-- The default maximum is unlimited, so an untimed run passes.
applyAgentCeiling defaultAgentCeiling (asking Nothing) @?= Right (asking Nothing)
-- | The default maximum is finite, so @unlimited@ is refused until the
-- operator opens it. The memory belongs to the operator's host.
outputLimitCeilingTest :: TestTree
outputLimitCeilingTest =
testCase "a finite max-output-limit refuses a larger capture and an unlimited one" $ do
let asking limit =
bounded (agentRunRequest AgentClaude "/tmp/work" "work") & #outputLimit .~ limit
unbounded = defaultAgentCeiling & #maxOutputLimit .~ Nothing
defaultMaxOutputLimit @?= 67108864
applyAgentCeiling defaultAgentCeiling (asking (Just 1024))
@?= Right (asking (Just 1024))
applyAgentCeiling defaultAgentCeiling (asking (Just defaultMaxOutputLimit))
@?= Right (asking (Just defaultMaxOutputLimit))
applyAgentCeiling defaultAgentCeiling (asking (Just (defaultMaxOutputLimit + 1)))
@?= Left [OutputLimitExceeded (Just (defaultMaxOutputLimit + 1)) defaultMaxOutputLimit]
applyAgentCeiling defaultAgentCeiling (asking Nothing)
@?= Left [OutputLimitExceeded Nothing defaultMaxOutputLimit]
applyAgentCeiling unbounded (asking Nothing) @?= Right (asking Nothing)
-- | Pin that both the requested and the permitted value appear, not
-- the exact sentence, so wording can improve without breaking tests.
violationRenderingTest :: TestTree
violationRenderingTest =
testCase "violation text names both the requested and the permitted value" $ do
let message = renderCeilingViolation (CapabilityExceeded AgentFullAccess AgentEditWorkspace)
assertBool
("expected the requested capability in: " <> Text.unpack message)
("full-access" `Text.isInfixOf` message)
assertBool
("expected the permitted maximum in: " <> Text.unpack message)
("edit-workspace" `Text.isInfixOf` message)
-- Raw provider arguments are the one part of a job description an
-- operator could write a credential into, so the refusal says how
-- many were requested and never what they were. Asserting the
-- absence is the point: a "helpful" edit that quoted them would
-- defeat the secret classification the configuration layer applies.
let argsMessage =
renderCeilingViolation (ProviderArgsForbidden ["--api-key", "sk-not-a-real-key"])
assertBool
("expected the count in: " <> Text.unpack argsMessage)
("2" `Text.isInfixOf` argsMessage)
assertBool
("expected no argument value in: " <> Text.unpack argsMessage)
(not ("sk-not-a-real-key" `Text.isInfixOf` argsMessage))
let providerMessage = renderCeilingViolation (ProviderForbidden AgentCodex [AgentClaude])
assertBool
("expected both providers in: " <> Text.unpack providerMessage)
("codex" `Text.isInfixOf` providerMessage && "claude" `Text.isInfixOf` providerMessage)
-- A grant refusal must name what to do about it, because the fix is
-- in a file the person reading the message may not know exists.
let grantMessage =
renderCeilingViolation (ToolGrantForbidden ["Bash", "Skill"] AgentEditWorkspace)
mapM_
( \fragment ->
assertBool
("expected " <> Text.unpack fragment <> " in: " <> Text.unpack grantMessage)
(fragment `Text.isInfixOf` grantMessage)
)
["Bash", "Skill", "edit-workspace", "policy.allowed-tools"]
-- Durations are rendered in the spellings the configuration parser
-- accepts, so an operator can paste the maximum back into their file.
let overTime = renderCeilingViolation (TimeoutExceeded (Just 10800) 7200)
untimed = renderCeilingViolation (TimeoutExceeded Nothing 7200)
assertBool
("expected both durations in: " <> Text.unpack overTime)
("3h" `Text.isInfixOf` overTime && "2h" `Text.isInfixOf` overTime)
assertBool
("expected the permitted maximum in: " <> Text.unpack untimed)
("2h" `Text.isInfixOf` untimed && "no timeout" `Text.isInfixOf` untimed)
let overBytes = renderCeilingViolation (OutputLimitExceeded (Just 99999999) 67108864)
unlimitedBytes = renderCeilingViolation (OutputLimitExceeded Nothing 67108864)
assertBool
("expected both byte counts in: " <> Text.unpack overBytes)
("99999999" `Text.isInfixOf` overBytes && "67108864" `Text.isInfixOf` overBytes)
assertBool
("expected the word unlimited in: " <> Text.unpack unlimitedBytes)
("unlimited" `Text.isInfixOf` unlimitedBytes && "67108864" `Text.isInfixOf` unlimitedBytes)
let scopeMessage = renderCeilingViolation (RepositoryScopeForbidden "executable")
assertBool
("expected the setting name in: " <> Text.unpack scopeMessage)
("executable" `Text.isInfixOf` scopeMessage)
let outsideMessage =
renderCeilingViolation (WorkingDirOutsideRepository "/etc" "/tmp/checkout")
assertBool
("expected both paths in: " <> Text.unpack outsideMessage)
("/etc" `Text.isInfixOf` outsideMessage && "/tmp/checkout" `Text.isInfixOf` outsideMessage)
capturedOutputTest :: TestTree
capturedOutputTest =
testCase "capturedBytes distinguishes uncaptured output from empty output" $ do
capturedBytes OutputNotCaptured @?= Nothing
capturedBytes (OutputCaptured "all of it") @?= Just "all of it"
capturedBytes (OutputTruncated "the first part") @?= Just "the first part"
capturedBytes (OutputCaptured "") @?= Just ""
failureRenderingTest :: TestTree
failureRenderingTest =
testCase "every render error and run failure produces actionable text" $ do
let renderErrors =
[ UnsupportedCapability AgentCodex AgentFullAccess "the sandbox cannot be disabled here",
UnsupportedToolRestriction AgentCodex "codex exec has no tool allow-list flag",
SafetyNotExpressible AgentClaude "claude has no sandbox mode",
ProviderMismatch AgentClaude AgentCodex,
CeilingRejected [CapabilityExceeded AgentFullAccess AgentReadOnly]
]
runFailures =
[ SpawnFailed "/usr/local/bin/claude" "no such file or directory",
RunTimedOut (AgentTimedOut 90 OutputNotCaptured OutputNotCaptured),
MissingEnvironment ["KEIRO_PATH", "ANTHROPIC_API_KEY"],
WorkingDirMissing "/tmp/gone"
]
mapM_
( \e ->
assertBool
("expected non-empty text for " <> show e)
(not (Text.null (renderAgentRenderError e)))
)
renderErrors
mapM_
( \f ->
assertBool
("expected non-empty text for " <> show f)
(not (Text.null (renderAgentRunFailure f)))
)
runFailures
let mismatch = renderAgentRenderError (ProviderMismatch AgentClaude AgentCodex)
assertBool
("expected both providers in: " <> Text.unpack mismatch)
("claude" `Text.isInfixOf` mismatch && "codex" `Text.isInfixOf` mismatch)
let unsupported =
renderAgentRenderError
(UnsupportedCapability AgentCodex AgentFullAccess "the sandbox cannot be disabled here")
assertBool
("expected the supplied explanation in: " <> Text.unpack unsupported)
("the sandbox cannot be disabled here" `Text.isInfixOf` unsupported)
let missing = renderAgentRunFailure (MissingEnvironment ["KEIRO_PATH", "ANTHROPIC_API_KEY"])
assertBool
("expected every missing variable in: " <> Text.unpack missing)
("KEIRO_PATH" `Text.isInfixOf` missing && "ANTHROPIC_API_KEY" `Text.isInfixOf` missing)
resultConstructorTest :: TestTree
resultConstructorTest =
testCase "agentRunResult records the process outcome and captures nothing" $ do
let result = agentRunResult AgentCodex (ExitFailure 3) 1.5
result ^. #provider @?= AgentCodex
result ^. #exitCode @?= ExitFailure 3
result ^. #duration @?= 1.5
result ^. #stdout @?= OutputNotCaptured
result ^. #stderr @?= OutputNotCaptured