Skip to content

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 the kyvvu init scaffold does not pass and the platform does not default, so the registration verdict for the scaffold as written is warn 0.50 rather than allow. 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/assignments with the agent's agent_id and the bundled manifest's manifest_path, and no repo_id. GET /api/v1/baseline lists the paths that are accepted (manifests, plus each profile's under profiles); any other path without a repo_id is rejected with 422. 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-engine alone does not get the frozen set. It is the kyvvu-baseline package, which kyvvu and kyvvu-cli install as a dependency. With the engine alone, run pip install kyvvu-baseline.

Next steps