> For the complete documentation index, see [llms.txt](https://docs.kyvvu.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kyvvu.com/policy-authoring/bundled-baseline.md).

# The Bundled Baseline

**What you'll learn:** What the 15-policy baseline set bundled inside the `kyvvu` package is, how to load it with no account and no network, why it is frozen, and where its licence boundary sits.

***

## Overview

The `kyvvu` 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.

| 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](/integrations/claude-code.md#default-policies). `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:

```bash
pip install kyvvu
kyvvu try                       # the bundled set against a demo agent path
kyvvu serve --baseline          # the same set over HTTP, still offline
```

In code:

```python
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:

```python
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 SDK (`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:

```python
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"]),
]
```

That `assert` is not decoration: `kyvvu-sdk/tests/test_baseline.py` executes every Python block on this page in order, so if the behaviour ever stops matching the table above, this page fails CI rather than quietly misleading a reader.

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      |

```python
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 |

```python
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 SDK, 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 is tracked upstream in [#234](https://github.com/Kyvvu/platform/issues/234), 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?":

```python
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 `kyvvu-manifests-public/manifests/security/owasp-agentic-default.yaml` and `.../compliance/eu-ai-act-minimal.yaml`, 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 SDK; 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 SDK 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` (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](/reference/licensing.md).
* **`pip install kyvvu-engine` alone does not get the frozen set.** It is a `kyvvu` (SDK) feature. Install the SDK, which is what most users install anyway and which pulls the engine in as a dependency.

***

## Next steps

* [kyvvu try](/cli-reference/try.md) — run this set, or your own manifest, with no account
* [OWASP Default Template](/policy-authoring/owasp-default.md) — all 8 OWASP policies in detail
* [Manifests](/policy-authoring/templates.md) — the other manifests, and how assignment works
* [Licensing](/reference/licensing.md) — the Apache-2.0 / BSL 1.1 boundary
* [Built-in Rules Reference](/policy-authoring/rules-reference.md) — the rules these policies use
