Control what an agent may do
Goal: make sure an agent can’t run a dangerous tool without permission. A
PermissionPolicy decides, for each tool call, whether to allow, ask for
approval, or deny it — before the tool runs.
Evaluating a policy needs no LLM, so this is fully offline and deterministic.
uv run examples/agent-permissions.py#!/usr/bin/env -S uv run --script# /// script# requires-python = ">=3.13"# dependencies = ["kaos-agents>=0.1.28,<0.2", "kaos-core>=0.1.4,<0.2"]# ///"""Decide which tool calls an agent may make — allow, ask, or deny.
Before an agent runs a tool, its `PermissionPolicy` decides what's allowed. Thedefault-safe policy auto-allows read-only tools and auto-asks for destructiveones; you add glob rules to allow/deny specific tools. Evaluating a policy needsno LLM, so this is fully offline and deterministic.
Run it:
uv run examples/agent-permissions.py"""
from __future__ import annotations
from kaos_core.types.metadata import ToolAnnotations
from kaos_agents.runtime.permissions import ( PermissionDecision, PermissionPolicy, PermissionRule,)
READ_ONLY = ToolAnnotations(readOnlyHint=True)DESTRUCTIVE = ToolAnnotations(destructiveHint=True)
def main() -> dict[str, str]: # 1. The default-safe policy: read-only auto-allowed, destructive auto-asked. safe = PermissionPolicy.default_safe() d_read = safe.evaluate("kaos-pdf-extract", READ_ONLY) d_destroy = safe.evaluate("kaos-agent-memory-clear", DESTRUCTIVE)
# 2. A custom policy: explicitly deny anything matching *-delete*, # and allow the web fetch tools outright. strict = PermissionPolicy( rules=( PermissionRule("*-delete*", PermissionDecision.DENY, "no deletes"), PermissionRule("kaos-web-*", PermissionDecision.ALLOW, "web reads ok"), ) ) d_delete = strict.evaluate("kaos-source-delete", DESTRUCTIVE) d_fetch = strict.evaluate("kaos-web-fetch", READ_ONLY)
decisions = { "default-safe: read-only tool": str(d_read), "default-safe: destructive tool": str(d_destroy), "strict: *-delete* tool": str(d_delete), "strict: kaos-web-* tool": str(d_fetch), } for label, decision in decisions.items(): print(f" {decision:<5} {label}") return decisions
if __name__ == "__main__": d = main() assert d["default-safe: read-only tool"] == "allow" assert d["default-safe: destructive tool"] == "ask" assert d["strict: *-delete* tool"] == "deny" assert d["strict: kaos-web-* tool"] == "allow"Notes
default_safe()reads tool annotations:readOnlyHinttools are auto-allowed,destructiveHinttools are auto-asked. Good defaults out of the box.- Glob rules (
PermissionRule(pattern, decision, reason)) override per tool — deny a family (*-delete*), allow another (kaos-web-*). First match wins. - An ASK decision pairs with the pause-and-resume
flow: the turn emits
ToolCallApprovalRequired, and the run can be resumed after a human approves. This is the boundary the refusal and session-enforcement contracts build on. - The policy is enforced by the Runner, so every tool a runtime exposes is gated the same way — you can’t forget to protect a new tool.