Safety¶
Kaval.AI’s mission is to make agentic AI pipelines predictable, observable,
and safe. Safety is not a single feature but a set of guarantees baked into
the runtime: data is typed at every boundary, control-flow expressions are
evaluated without eval, agents are explicitly bounded, and any pipeline can
be run fully offline for deterministic testing.
For a hands-on walkthrough, see Workflows.
Typed I/O everywhere¶
Every value that crosses a boundary is validated. Workflow data_types are
JSON-schema fragments compiled to Pydantic models, so each node input and output
is checked (see Workflows). Likewise, every tool in the
FunctionKernel has a Pydantic input and output model, and the
kernel validates and coerces arguments before a call and the return value after
(see Tools). A malformed value is caught at the boundary, not deep inside
a downstream step.
A safe expression language¶
Branching nodes (if / switch) and context lookups are powered by a small
expression language in kavalai.workflow.expressions:
evaluate_expression(), evaluate_bool, evaluate_value, and
ExpressionError.
Expressions are evaluated safely via an AST whitelist — never Python eval.
Only comparisons, and / or / not, in, arithmetic, and
dotted/indexed access into the context are permitted. Unknown names resolve to
None, so guard checks degrade gracefully rather than crashing.
Bounded agents, explicit tools¶
Agentic loops are constrained on two fronts. An Agent (and the
agent workflow node) is bounded by an explicit max_steps so it cannot
run away (see Agents). And it can only act through tools addressed
explicitly by URI — there is no implicit capability, only the tools you have
registered with the kernel.
Outbound requests¶
A tool that fetches a URL the model composes is a path for server-side request
forgery: a prompt can steer the model towards http://169.254.169.254/,
where a cloud instance serves its credentials, or towards a database port on
localhost. kavalai.net refuses such targets, and the bundled
http_request and crawl_url tools apply it by default (see
Bundled tools).
is_public_address() accepts only globally routable unicast
addresses; an IPv4-mapped or NAT64 IPv6 address is judged as the IPv4 address
it reaches. ensure_public_url() checks a URL: the scheme,
credentials or a backslash in the authority, internal names such as
localhost and *.internal, and every address the host resolves to. IP
literals are read in every encoding the C library accepts, and non-ASCII names
are IDNA-encoded before the name checks run.
from kavalai.net import UnsafeUrlError, ensure_public_url, is_public_address
print(is_public_address("93.184.216.34"))
print(is_public_address("::ffff:169.254.169.254"))
for url in (
"http://0x7f.1:8080/admin",
"http://printer.local/",
"https://api.open-meteo.com/v1/forecast",
):
try:
print(await ensure_public_url(url))
except UnsafeUrlError as error:
print("refused:", error)
True
False
refused: 127.0.0.1 (0x7f.1) is a non-public address
refused: printer.local is an internal host name
https://api.open-meteo.com/v1/forecast
A check on the URL alone is defeated by DNS rebinding: the name resolves to a
public address when it is checked and to an internal one when the client
resolves it again to connect. PublicOnlyTransport
removes the second lookup. Its httpcore network backend resolves the host,
checks every address and opens the socket to the address it checked, while TLS
verifies the certificate against the host name and sends it as SNI. Each
redirect is a new request through the same transport and is checked in the
same way. The transport serves any httpx client in your own tools:
import httpx
from kavalai.net import PublicOnlyTransport, UnsafeUrlError
async with httpx.AsyncClient(transport=PublicOnlyTransport()) as client:
response = await client.get("https://example.com/")
print(response.status_code)
try:
await client.get("http://localhost:8000/")
except UnsafeUrlError as error:
print("refused:", error)
200
refused: localhost is an internal host name
The guard has two limits, both stated with the tools. With use_proxy=True
the proxy resolves the name, so http_request can apply only the URL check.
crawl_url drives a browser in another process, so only the requested and
the final URL are checked; full protection there requires network egress
control around the browser. An agent meant to reach an intranet is given a
tool built with allow_private_networks=True — an argument of the tool’s
factory, not of the tool, so the switch rests with whoever registers the tool
and never with the model.
Deterministic testing¶
A model’s answer varies between calls, so a test of workflow logic —
branching, data flow, node wiring — replaces the model and keeps everything
else. kavalai.testing supplies the replacement.
ScriptedLlmClient answers each call with the next
reply from a list, and the engine accepts it as its client_factory. Here it
drives the council desk workflow of Cookbook:
import asyncio
from kavalai import WorkflowEngine
from kavalai.testing import ScriptedLlmClient
model = ScriptedLlmClient(
[
{"intent": "permit"},
{"agent_response": "Apply at the parish office."},
]
)
engine = WorkflowEngine.from_yaml_path(
"council_desk.yaml", client_factory=model
)
state = asyncio.run(engine.run({"user_message": "May I build a shed?"}))
print(state.trace)
print(state.output_data)
print(model.calls[1].model, model.calls[1].response_model.__name__)
['start', 'classify', 'route', 'permit_reply', 'end']
{'agent_response': 'Apply at the parish office.'}
openai/gpt-5.6-luna output
Only the provider call is replaced. Each reply passes through the same
streamer, retry and restart handling as a provider’s answer, and the engine
validates it into the node’s data type, so a scripted reply that does not fit
the type fails the run exactly as a model’s reply would. The client records
every request in model.calls — the last line shows that the second call was
made as openai/gpt-5.6-luna and asked for the output data type — and
reports a ModelCallStat for each call, with token counts
estimated from the text, so state.token_usage and a task logger see the run
as they would in production.
Workflow logic thereby becomes testable and repeatable without a single live
model call, which complements the run-level auditing described in
Observability. fake_providers() extends the
substitution to a workflow that names its model rather than receiving a
factory, and FakeEmbeddingClient extends it to
retrieval; the cookbook recipe “Testing a workflow without calling a model”
covers both.