The Bundled Baseline¶
What you'll learn: What the 15-policy baseline set bundled inside kyvvu-baseline is, how to load it with no account and no network, why it is frozen, and where its licence boundary sits.
Overview¶
The kyvvu-baseline package ships a frozen baseline policy set: 15 policies that make an agent governed without an account, an API key, or a network round trip. The kyvvu client installs it as a dependency.
| Manifest | Policies | What it contributes |
|---|---|---|
owasp-agentic-default |
8 | OWASP Top 10 for Agentic Applications (2026). The set's only source of critical step policies — four of them, and therefore the block. |
eu-ai-act-minimal |
7 | EU AI Act Article 50 transparency plus registration-time documentation. Both step policies are high, so they warn, and both fire on delivery (an outbound step.message POST, or a step.resource POST to an external target), not on the model call. |
Eight of the 15 are evaluated at agent_registration and seven at step_execution. Both manifests are assigned to the same agent on the platform; distinct manifests can share policy names and enforce each copy.
One exception on the platform: Claude Code agents. An agent registered by
kyvvu-claude (metadata.integration: kyvvu-claude) gets Claude Code Safety
(13 policies) in place of the OWASP template, alongside the same EU AI Act
manifest. Claude Code records no approval gate, so "Code execution requires a
preceding gate" would deny every Bash call, and the tool allowlist cannot name
MCP tools. See Claude Code.
load_baseline_policies() and kyvvu try are unaffected: they are the 15
above. kyvvu_baseline.profile_manifests() returns either set.
Loading it¶
The fastest route is the CLI, which needs no code at all:
uv tool install kyvvu-cli
kyvvu try # the bundled set against a demo agent path
kyvvu serve --baseline # the same set over HTTP, still offline
In code:
from kyvvu_baseline import load_baseline_policies
from kyvvu_engine import PolicyEngine
engine = PolicyEngine()
engine.load_policies(load_baseline_policies())
report = engine.load_report()
print(report.state.value, report.offered, report.enforcing)
assert (report.offered, report.enforcing) == (15, 15)
load_baseline_policies() reads only the two YAML files bundled inside the package, via importlib.resources — so it works from an installed wheel, in a container, and on an airgapped machine. It takes no arguments, makes no HTTP request, and reads no environment variable. Each call returns an independent deep copy, so mutating the result cannot affect the next caller.
Always check load_report() rather than policy_count(): a count of zero cannot tell "nothing assigned" from "every policy was rejected", and both allow everything.
Declare the agent's tools, or every tool call blocks¶
Evaluation needs an EvalContext, and one field of it is not optional in practice. The OWASP allowlist policy reads agent_allowed_tools from the context — not declared_tools from the agent payload — and it fails closed: with the allowlist absent or empty, every step.resource, step.exec and step.credential is blocked with "Tool calls must be in the declared allowlist".
That is the correct default for a governance product, and it is also the single easiest way to get a confusing first run: a minimal EvalContext(agent_id=..., task_id=...) blocks step one and attributes step four's block to the allowlist rather than to OWASP ASI05. So declare the tools:
from kyvvu_engine import Action, Behavior, EvalContext, ActionKind, Verb
context = EvalContext(
agent_id="hello-kyvvu",
task_id="task-1",
risk_classification="minimal",
agent_allowed_tools=["fetch_user_data", "call_llm", "send_reply", "run_script"],
)
When you drive the engine through the Python client (Kyvvu + @kv.step or a framework handler), this is filled in from Kyvvu(declared_tools=[...]), KV_DECLARED_TOOLS, or an explicit register_agent(declared_tools=[...]). Declare it on EvalContext yourself when calling PolicyEngine directly.
What it does to a four-step agent¶
The agent scaffolded by kyvvu init calls fetch_user_data, then call_llm, then send_reply to deliver the model's answer with no gate after the model call, then run_script with no gate in front of it:
| Step | Action | Risk score | Policy |
|---|---|---|---|
fetch_user_data (step.resource GET) |
allow |
0.00 | — |
call_llm (step.model) |
allow |
0.00 | — (a model call is not a delivery; see below) |
send_reply (step.message POST) |
warn |
0.75 | AI-generated content must be marked before delivery (EU AI Act Art. 50(2), high); Chatbot disclosure gate required before outbound messages (Art. 50(1), high) |
run_script (step.exec) |
block |
1.00 | Code execution requires a preceding gate (OWASP ASI05, critical) |
The two verdicts are different in kind. The warn is a compliance finding: model output reached a person unmarked and with no AI disclosure, the step still runs, and the finding is recorded. The block is a security stop: code execution with no approval in front of it never runs.
Continuing from the snippet above — a step is only part of task history once it is recorded, and a blocked step is never recorded:
steps = [
("fetch_user_data", ActionKind.step_resource, Verb.GET),
("call_llm", ActionKind.step_model, None),
("send_reply", ActionKind.step_message, Verb.POST),
("run_script", ActionKind.step_exec, None),
]
decisions = []
for step_name, kind, verb in steps:
behavior = Behavior(
agent_id="hello-kyvvu",
task_id="task-1",
kind=kind,
verb=verb,
step_name=step_name,
input={"query": "What's the weather?"},
)
result = engine.evaluate(behavior, context)
violated = [policy.name for policy in result.policies if policy.violated]
decisions.append((step_name, result.action.value, result.risk_score, violated))
if result.action != Action.block:
engine.record(behavior)
assert decisions == [
("fetch_user_data", "allow", 0.00, []),
("call_llm", "allow", 0.00, []),
(
"send_reply",
"warn",
0.75,
[
"AI-generated content must be marked before delivery",
"Chatbot disclosure gate required before outbound messages",
],
),
("run_script", "block", 1.00, ["Code execution requires a preceding gate"]),
]
Every Python block on this page runs in the documentation tests, so if the behaviour ever stops matching the table above, this page fails the build.
The EU AI Act set says nothing about call_llm itself, and that is correct: its step policies govern what reaches a person or an outside system, so it speaks up at send_reply, where the model's output is delivered. Article 50(2) marking applies to the model's output, so "AI-generated content must be marked before delivery" asks for a step.gate after the most recent step.model and before the step that delivers it. A model call on its own, or a delivery in a task with no model output, does not trigger it.
In the table, model is a step.model, gate a step.gate whose output.decision is "pass",
and reply an outbound message: a step.message with verb POST.
| Path | Verdict on the last step |
|---|---|
| model | allow |
| model → reply | warn 0.75 — content not marked, and no chatbot disclosure (Art. 50(1)) |
| model → gate → reply | allow — one passing gate after the model satisfies both |
| gate → model → reply | warn 0.75 — a gate before the model cannot have marked its output |
MARKING = "AI-generated content must be marked before delivery"
DISCLOSURE = "Chatbot disclosure gate required before outbound messages"
def last_verdict(path):
"""Record every step but the last, then evaluate the last."""
fresh = PolicyEngine()
fresh.load_policies(load_baseline_policies())
for index, (kind, verb, output) in enumerate(path):
behavior = Behavior(
agent_id="hello-kyvvu",
task_id="task-1",
kind=kind,
verb=verb,
step_name=f"step_{index}",
properties={"kind": "guardrail"} if kind == ActionKind.step_gate else {},
output=output,
)
if index == len(path) - 1:
result = fresh.evaluate(behavior, context)
violated = {policy.name for policy in result.policies if policy.violated}
return result.action.value, result.risk_score, violated
fresh.record(behavior)
model = (ActionKind.step_model, None, None)
gate = (ActionKind.step_gate, None, {"decision": "pass"})
reply = (ActionKind.step_message, Verb.POST, None)
assert last_verdict([model]) == ("allow", 0.00, set())
assert last_verdict([model, reply]) == ("warn", 0.75, {MARKING, DISCLOSURE})
assert last_verdict([model, gate, reply]) == ("allow", 0.00, set())
assert last_verdict([gate, model, reply]) == ("warn", 0.75, {MARKING})
denied = (ActionKind.step_gate, None, {"decision": "deny"})
undecided = (ActionKind.step_gate, None, None)
assert last_verdict([model, denied, reply]) == ("warn", 0.75, {DISCLOSURE})
assert last_verdict([model, undecided, reply]) == ("warn", 0.75, {DISCLOSURE})
That is why both manifests ship rather than one: the OWASP set owns the block, and the EU AI Act set layers a citable warn on delivery without stealing it. Only critical reaches weight 1.0 and blocks; high, medium and low warn. An EU-only set contains no critical step policy, so it produces no block at all on a typical agent.
The two policies do not ask the same thing of the gate. The marking policy accepts any step.gate between the model output and its delivery, whatever its properties.kind or output.decision. The chatbot disclosure policy (step_requires_gate) accepts only a gate whose output.decision is exactly "pass". So one passing gate after the model satisfies both, while a gate recorded with decision: "deny", or with no output at all, clears the marking finding and still leaves warn 0.75 with "Chatbot disclosure gate required before outbound messages" (the last two asserts above). Policies cannot yet name a gate's purpose, so the marking gate is not told apart from any other gate.
Clearing the block: a gate must record a result¶
The block is path-dependent, not a ban on step.exec. But "add a gate" is not enough. step_requires_gate looks for a completed step.gate in task history whose output.decision is exactly "pass":
Gate recorded before run_script |
Verdict |
|---|---|
| none | block 1.00 |
step.gate with no properties |
block 1.00 |
properties.kind: human, no output |
block 1.00 |
output.decision: "deny" |
block 1.00 |
output.decision: "pass" |
allow 0.00 |
gate = Behavior(
agent_id="hello-kyvvu",
task_id="task-1",
kind=ActionKind.step_gate,
step_name="human_approval",
properties={"kind": "human"}, output={"decision": "pass"},
)
engine.record(gate)
Through the Python client, the same thing is one call: kv.add_step(ActionKind.step_gate, "human_approval", properties={"kind": "human"}, output={"decision": "pass"}).
A rough edge to know about¶
It is in the manifests rather than in this loader, it remains an upstream issue, and it is not papered over by the tests:
- The scaffolded demo agent warns at registration. "Agent must have a designated owner" reads
owner_id, which thekyvvu initscaffold does not pass and the platform does not default, so the registration verdict for the scaffold as written iswarn0.50 rather thanallow. Worth knowing before the baseline is applied automatically at registration.
Provenance¶
baseline_manifests() is the assert-able answer to "where did this policy come from?":
from kyvvu_baseline import baseline_manifests
for manifest in baseline_manifests():
print(manifest.id.value, manifest.version, manifest.policy_count, manifest.license)
print(manifest.source_path)
print(manifest.policy_names)
Every policy dict also carries manifest_name, so a caller reading a policy — or rendering a violation — can name the manifest it came from without re-reading the YAML. manifest_id is deliberately left unset: it means "Assignment row id" on the platform, and a bundled policy has no assignment behind it.
The bundled files are byte-identical copies of manifests/security/owasp-agentic-default.yaml and manifests/compliance/eu-ai-act-minimal.yaml in the public Kyvvu/manifests repository, and a test enforces that, so the bundle cannot drift from the source of truth.
Frozen, and bundled rather than fetched¶
Frozen means there is no supported knob: no flag, no environment variable, and no per-policy enable/disable. The set is the set. This is a product boundary rather than a security boundary — a local process can always reach past it, and no effort is spent pretending otherwise. The bundle is version-pinned in the Python client; it makes no claim to resist changes by the local process.
Bundled rather than fetched because fetching from GitHub or a Kyvvu endpoint would make "no account, no network" false, put a first run behind someone else's uptime, and inherit the unauthenticated GitHub rate limit. Bundling is version-pinned and reproducible: an evaluator's demo cannot break because a manifest was edited upstream.
Freshness lives where it matters. The authenticated path already fetches current policies from the platform, which is also where assignments, incidents and the audit trail live. Bundle = free and frozen; fetch = authenticated and current.
Removing and restoring the baseline¶
On the platform the baseline is on by default, not mandatory. Unassigning a baseline manifest from an agent (the Agents page, or kyvvu unassign-manifest --assignment-id <id> with the id from the ID column of kyvvu list-assignments --agent-id <agent key>) disables its policies, and the platform treats that as a decision. The Python client registers the agent on every process start, so registering again never re-applies a manifest you removed.
Putting it back is a separate, deliberate act:
- Dashboard. Open Manifests. The bundled baseline is listed there as Kyvvu baseline, next to your connected repositories, and each manifest has an Assign button. The agent's row on the Agents page also still shows the baseline after you have removed all of it, so you can see what was removed.
- API.
POST /api/v1/assignmentswith the agent'sagent_idand the bundled manifest'smanifest_path, and norepo_id.GET /api/v1/baselinelists the paths that are accepted (manifests, plus each profile's underprofiles); any other path without arepo_idis rejected with422. It needs the same permissions as any other assignment.
The restored assignment is exactly what registration would have created: tagged Baseline, pinned to the bundled file's SHA-256 and declared version. Restoring a manifest whose name is already assigned to the agent returns 409, including when a repository copy is active. Distinct manifests can layer even when some of their policy names overlap.
Until you restore it, kyvvu doctor and the dashboard checklist report the agent as having no policies in effect, and every step it takes is allowed.
Licensing — read this before assuming the trial is Apache-2.0¶
The bundled policy content is Apache-2.0, like the manifests repository it is copied from. That is why it ships in kyvvu-baseline (Apache-2.0) and not in kyvvu-engine (BSL 1.1): policy content must carry the licence of the manifests it came from.
Two consequences, both deliberate:
- Apache-2.0 manifests do not make the trial Apache-licensed end to end. The BSL 1.1 engine still evaluates them, so free use of the loaded baseline remains bounded by the BSL's non-production terms. See Licensing.
pip install kyvvu-enginealone does not get the frozen set. It is thekyvvu-baselinepackage, whichkyvvuandkyvvu-cliinstall as a dependency. With the engine alone, runpip install kyvvu-baseline.
Next steps¶
- kyvvu try — run this set, or your own manifest, with no account
- OWASP Default Template — all 8 OWASP policies in detail
- Manifests — the other manifests, and how assignment works
- Licensing — the Apache-2.0 / BSL 1.1 boundary
- Built-in Rules Reference — the rules these policies use