0021. Shared policy catalog, operator deny rules, and a server-side mirror¶
- Status: accepted
- Date: 2026-06-06
Context and Problem Statement¶
ADR-0020 introduced a deterministic, always-on safety guard on the agent that refuses
individually dangerous tool calls (error.code = "blocked"). It listed three follow-ups,
which this ADR implements:
- An append-only operator extension list — let the operator add deny rules on top of the compiled-in built-ins, without ever being able to weaken them.
- A server-side mirror — refuse an obviously dangerous call before forwarding it, so Claude/the operator get immediate feedback instead of a tunnel round-trip.
- Guard hits as an audit signal — make a refusal visible in the existing event store.
The hard constraint is the repo invariant "Python and Rust must not drift." A server mirror that re-implements the agent's rules in Python is exactly the kind of duplication that drifts. We need one source of truth that both runtimes consume.
Considered Options¶
- Shared single-source catalog file consumed by both sides (chosen). One
docs/policy/deny_rules.jsonof{id, applies_to, pattern, reason}rules. The Rust agent embeds it at build time (include_str!, so the binary stays self-contained); the Python server loads the same file for its mirror. No second hand-maintained rule list. - Independent Python re-implementation with a parity test. Less coupling, but two rule lists that a test must police forever — the drift the invariant warns against.
- Operator list as a local endpoint file (like the kill-switch control file). Rejected for this change: the operator is the central server-side admin, so a centrally managed, tunnel-delivered list matches how operators actually work and surfaces in the dashboard.
- Guard hits as a new telemetry section / frame. Rejected: the
logframe (ADR-0017) and event store already are the audit trail; atracing::warn!on each block flows there with no contract change.
Decision Outcome¶
- Shared catalog.
docs/policy/deny_rules.jsonis the single source of truth for the built-in rules.applies_to∈ {powershell,self_protection,path}; patterns use the portable regex subset common to Rustregexand Pythonre(no backreferences/lookaround). Self-protection patterns carry the literalkenny-agent/kenny-agent.control.jsonstrings; an agent unit test asserts they stay in lockstep with theSERVICE_NAME/CONTROL_FILEconstants. - Operator rules over the tunnel. A new
policyframe (server → agent) carries the operator's append-only extra deny rules. They are additive to the built-ins (which this frame can never weaken or remove). The server persists the list (SQLite), exposes an authenticated/apiroute to read/add/remove operator rules, sends the current list on each agentregister, and re-pushes to all connected agents on change. The agent recompiles its rule set on each frame; a rule that fails to compile is skipped (logged), never fatal.PROTOCOL_VERSIONbumps0.5→0.6(additive frame). - Server mirror (best-effort). Before forwarding a capability call, the server runs the
same catalog + operator rules over the args (
powershell_exec.script,fs_*path/root, winget/net string args for self-protection). A hit raisesToolError("blocked", reason)without forwarding. If the catalog file is absent at runtime (e.g. an unusual deploy), the mirror degrades to disabled with a warning — it is UX, not the boundary. Theagent_updatehost allowlist stays agent-only (it needs the agent's configured server host, which the server cannot self-determine). - Audit signal. On every block, the agent emits a structured
tracing::warn!(forwarded via thelogframe to the event store, ADR-0017); the server mirror records the same via the event store. Both name the matched rule.
Deployment note¶
The server image (kenny-server/Dockerfile) and .dockerignore previously excluded
docs/. The build now ships docs/policy/ so the mirror can load the catalog in the
container; the loader also honours KENNY_POLICY_CATALOG and falls back to mirror-disabled
when the file is missing.
Consequences¶
- Good: one rule list, consumed by both runtimes — no Python/Rust drift. The agent stays the authoritative, self-contained enforcement point; the server mirror only ever adds earlier feedback and can fail open without weakening safety.
- Good: operators get a central, append-only way to extend the blocklist; built-ins remain un-removable; every block is auditable through the existing event store.
- Bad / trade-offs: the agent build now embeds a file from
docs/; the server build must shipdocs/policy/; the operator list is new persisted state with its own API surface. A determined, obfuscated payload can still slip a regex blocklist — unchanged from ADR-0020, this is defense-in-depth below auth + confirm-gate + kill-switch, not a sandbox.
More Information¶
- Contract:
docs/protocol.md(policyframe, versioning),docs/fixtures/policy.json,docs/policy/deny_rules.json. - Code:
kenny-agent/src/policy.rs,kenny-agent/src/tunnel.rs,kenny-agent/src/dispatch.rs,kenny-agent/src/protocol.rs;kenny-server/kenny_server/{protocol,tunnel,store,policy,webui,main}.py. - Related: ADR-0020 (the guard),
ADR-0011 (kill-switch precedent for
control state), ADR-0017 (event store /
logframe), ADR-0005 (contract-first).