Skip to content

Permissions

Heartbit’s permission system controls which tools an agent can execute and under what conditions. It combines declarative rules, human-in-the-loop approval, and persistent learned permissions.

Rules are evaluated in order for each tool call — first match wins. Each rule specifies a tool name, an input pattern, and an action.

ActionBehavior
allowExecute without asking
denyReject without asking (agent receives an error result)
askDefer to the on_approval callback
  • Tool name: exact match, or "*" to match all tools
  • Pattern: glob matched against all string values in the tool’s JSON input. "*" matches everything. Supports * (any characters) and ? (single character).

If no rule matches, the tool call falls through to the on_approval callback (if set), or executes directly.

# Allow all read operations
[[permissions]]
tool = "read_file"
action = "allow"
# Block destructive bash commands
[[permissions]]
tool = "bash"
pattern = "rm *"
action = "deny"
# Block access to .env files across all tools
[[permissions]]
tool = "*"
pattern = "*.env*"
action = "deny"
# Everything else: ask the human
[[permissions]]
tool = "*"
action = "ask"

Rules are evaluated top to bottom. Place specific rules before general ones.

use heartbit::AgentRunner;
use heartbit::agent::permission::{PermissionRuleset, PermissionRule, PermissionAction};
let rules = PermissionRuleset::new(vec![
PermissionRule {
tool: "read_file".into(),
pattern: "*".into(),
action: PermissionAction::Allow,
},
PermissionRule {
tool: "bash".into(),
pattern: "rm *".into(),
action: PermissionAction::Deny,
},
PermissionRule {
tool: "*".into(),
pattern: "*".into(),
action: PermissionAction::Ask,
},
]);
let runner = AgentRunner::builder(provider)
.name("worker")
.system_prompt("You are a helpful assistant.")
.permission_rules(rules)
.build()?;

Use PermissionRuleset::allow_all() to bypass all permission checks.

The on_approval callback is invoked when a tool call has action Ask or when no permission rule matches (and the callback is set).

use heartbit::llm::ApprovalDecision;
let runner = AgentRunner::builder(provider)
.name("worker")
.system_prompt("You are a helpful assistant.")
.on_approval(Arc::new(|tool_calls| {
// Inspect tool_calls and decide
ApprovalDecision::Allow
}))
.build()?;
DecisionEffect
AllowExecute this time
DenyReject this time
AlwaysAllowExecute and persist as a permission rule
AlwaysDenyReject and persist as a permission rule

When the user chooses AlwaysAllow or AlwaysDeny, the decision is injected into the live permission ruleset. Future matching tool calls skip the approval prompt.

Enable interactive approval with the --approve flag:

Terminal window
heartbit run --config heartbit.toml --approve "Deploy the application"

The CLI prompts before each tool execution, with options for Allow, Deny, AlwaysAllow, and AlwaysDeny.

Persistent approval decisions are saved to a TOML file (default: ~/.config/heartbit/permissions.toml). These survive across sessions.

use heartbit::agent::permission::LearnedPermissions;
let learned = LearnedPermissions::load(
&LearnedPermissions::default_path().unwrap()
)?;
let runner = AgentRunner::builder(provider)
.name("worker")
.system_prompt("You are a helpful assistant.")
.learned_permissions(Arc::new(std::sync::Mutex::new(learned)))
.build()?;
  1. User chooses AlwaysAllow for bash tool
  2. A rule { tool: "bash", pattern: "*", action: "allow" } is added to the live ruleset
  3. The same rule is saved to ~/.config/heartbit/permissions.toml
  4. On next session, the learned rules are loaded and appended to the ruleset

Config rules retain priority over learned rules (first match wins), since learned rules are appended after config rules.

[[rules]]
tool = "read_file"
action = "allow"
[[rules]]
tool = "bash"
pattern = "rm *"
action = "deny"

Duplicate rules (same tool + pattern + action) are silently skipped.

The full evaluation pipeline for each tool call:

Tool call
|
v
Permission rules (first match wins)
|
+-- Allow --> Execute
+-- Deny --> Error result to agent
+-- Ask --> on_approval callback
+-- No match --> on_approval callback (if set), else execute
|
+-- Allow/AlwaysAllow --> Execute
+-- Deny/AlwaysDeny --> Error result

When AlwaysAllow or AlwaysDeny is returned, the decision is also persisted as a new rule in the live ruleset (and on disk if learned permissions are configured).

Permission-related events are emitted through the observability system:

  • approval_requested — human approval prompt sent (includes tool names)
  • approval_decision — human response received (approved or denied)
  • guardrail_denied — if a guardrail (separate from permissions) blocks a tool
[[permissions]]
tool = "read_file"
action = "allow"
[[permissions]]
tool = "grep"
action = "allow"
[[permissions]]
tool = "glob"
action = "allow"
[[permissions]]
tool = "*"
action = "ask"
[[permissions]]
tool = "*"
pattern = "*.env*"
action = "deny"
[[permissions]]
tool = "*"
pattern = "*secret*"
action = "deny"
[[permissions]]
tool = "*"
pattern = "*password*"
action = "deny"
[[permissions]]
tool = "*"
action = "allow"