Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions src/openhuman/tinyagents/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ use crate::openhuman::tinyagents::payload_summarizer::PayloadSummarizer;
use crate::openhuman::tokenjuice::AgentTokenjuiceCompression;
use crate::openhuman::tools::Tool;

use super::policy_denial::PolicyDenial;

/// Default per-tool-result byte cap for the channel / sub-agent paths, which do
/// not carry a session `ContextManager` to source the configured budget from.
/// Mirrors the `ContextConfig::tool_result_budget_bytes` default (16 KiB).
Expand Down Expand Up @@ -1232,22 +1234,28 @@ impl ToolPolicyMiddleware {
fn channel_permission_block(&self, call: &TaToolCall) -> Option<String> {
let decision = self.session.decision_for(&call.name);
if decision.is_denied() {
let required = decision
.required_permission
.map(|permission| permission.to_string())
.unwrap_or_else(|| "unknown".to_string());
return Some(format!(
"Tool '{}' blocked by tool policy: requires {}, channel '{}' allows {}",
call.name, required, self.channel, decision.allowed_permission
));
return Some(
PolicyDenial::SessionForbidden {
tool: &call.name,
required: decision.required_permission,
allowed: decision.allowed_permission,
channel: &self.channel,
}
.render(),
);
}
let tool = self.resolve_tool(&call.name)?;
let call_required = tool.permission_level_with_args(&call.arguments);
if call_required > decision.allowed_permission {
return Some(format!(
"Tool '{}' action requires {} permission, channel '{}' allows {}",
call.name, call_required, self.channel, decision.allowed_permission
));
return Some(
PolicyDenial::PermissionTooLow {
tool: &call.name,
required: call_required,
allowed: decision.allowed_permission,
channel: &self.channel,
}
.render(),
);
}
None
}
Expand Down Expand Up @@ -1333,11 +1341,19 @@ impl ToolMiddleware<()> for ToolPolicyMiddleware {
reason = %reason,
"[tinyagents::mw] tool blocked by policy"
);
let content = format!(
"Tool '{}' {blocked_action} by policy '{}': {reason}",
call.name,
self.policy.name()
);
let content = match &decision {
ToolPolicyDecision::RequireApproval { .. } => PolicyDenial::ApprovalRequired {
tool: &call.name,
policy: self.policy.name(),
reason,
},
_ => PolicyDenial::PolicyDenied {
tool: &call.name,
policy: self.policy.name(),
reason,
},
}
.render();
return Ok(MiddlewareToolOutcome::Result(TaToolResult {
call_id: call.id,
name: call.name,
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/tinyagents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod model;
pub(crate) mod observability;
pub(crate) mod orchestration;
pub(crate) mod payload_summarizer;
mod policy_denial;
pub(crate) mod retriever;
mod routes;
mod run_cancellation_context;
Expand Down
231 changes: 231 additions & 0 deletions src/openhuman/tinyagents/policy_denial.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
//! Structured, actionable messages for policy / permission denials.
//!
//! When the harness blocks a tool call at a policy or permission boundary, the
//! agent must not dead-end with a bare "blocked" line. Each denial is rendered
//! into a structured message — **what** was blocked, **why**, and a concrete
//! **workaround** (how to enable it, or a permitted alternative) — followed by
//! an explicit instruction to relay it to the user rather than halting
//! silently. The rendered string is returned as the (failed) tool result, so it
//! flows back into the turn the same way the unknown-tool corrective error is
//! surfaced to the model (see PR #4360).

use crate::openhuman::tools::PermissionLevel;

/// The boundary that blocked a tool call, with the context needed to explain it
/// and suggest a way forward.
pub(super) enum PolicyDenial<'a> {
/// The session tool policy forbids this tool for the channel's permission
/// tier (it is not in the allowed set).
SessionForbidden {
tool: &'a str,
required: Option<PermissionLevel>,
allowed: PermissionLevel,
channel: &'a str,
},
/// The tool is allowed in general, but *this call's* arguments require a
/// higher permission than the channel grants.
PermissionTooLow {
tool: &'a str,
required: PermissionLevel,
allowed: PermissionLevel,
channel: &'a str,
},
/// A pluggable `ToolPolicy`
/// denied the call outright.
PolicyDenied {
tool: &'a str,
policy: &'a str,
reason: &'a str,
},
/// A pluggable `ToolPolicy`
/// requires an approval handoff this executor cannot complete inline.
ApprovalRequired {
tool: &'a str,
policy: &'a str,
reason: &'a str,
},
}

/// Suffix appended to every denial so the agent relays the block instead of
/// silently stopping.
const RELAY_INSTRUCTION: &str = "Relay this to the user: explain what was \
blocked and why, then offer the workaround as the next step. Do not stop \
silently.";

impl PolicyDenial<'_> {
/// Render the denial as a structured `Blocked / Reason / Workaround / relay`
/// message for the model.
pub(super) fn render(&self) -> String {
let (blocked, reason, workaround) = match self {
PolicyDenial::SessionForbidden {
tool,
required,
allowed,
channel,
} => {
let reason = match required {
Some(required) => format!(
"it requires {required} permission, but the '{channel}' channel only \
grants {allowed} access"
),
None => format!(
"it is not permitted at the '{channel}' channel's {allowed} access tier"
),
};
(
format!("Tool '{tool}' is blocked by the session tool policy"),
reason,
raise_tier_workaround(
required.map(|p| p.to_string()).as_deref(),
*allowed,
channel,
),
)
}
PolicyDenial::PermissionTooLow {
tool,
required,
allowed,
channel,
} => (
format!("Tool '{tool}' is blocked by a per-call permission check"),
format!(
"this call needs {required} permission, but the '{channel}' channel only \
grants {allowed} access"
),
raise_tier_workaround(Some(&required.to_string()), *allowed, channel),
),
PolicyDenial::PolicyDenied {
tool,
policy,
reason,
} => (
format!("Tool '{tool}' was denied by policy '{policy}'"),
(*reason).to_string(),
"Address the reason above, or reach the goal with a permitted alternative tool / \
path. If this action is genuinely required, ask the user to adjust the policy."
.to_string(),
),
PolicyDenial::ApprovalRequired {
tool,
policy,
reason,
} => (
format!("Tool '{tool}' requires approval under policy '{policy}'"),
(*reason).to_string(),
"Ask the user to approve this action, then retry — or choose an alternative that \
does not require approval."
.to_string(),
),
};

format!(
"Blocked: {blocked}. Reason: {reason}. Workaround: {workaround} {RELAY_INSTRUCTION}"
)
}
}

/// Workaround shared by the permission-tier denials: raise the channel's
/// agent-access tier, or fall back to a lower-permission tool.
fn raise_tier_workaround(
required: Option<&str>,
allowed: PermissionLevel,
channel: &str,
) -> String {
match required {
Some(required) => format!(
"Raise the '{channel}' channel's agent-access tier to at least {required} \
(Settings → Agent access, or the `config.update_autonomy_settings` RPC / \
`[autonomy]` config), or accomplish the goal with a tool that needs only \
Comment on lines +137 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point permission denials at the channel permission knob

When a tool is blocked by the channel permission ceiling, this workaround sends the user to config.update_autonomy_settings / [autonomy], but the ceiling being enforced here comes from AgentConfig.channel_permissions via ToolPolicyEngine::build_session, not the autonomy access-mode config. In a read-only channel, a user can follow this instruction and switch readonly/supervised/full without changing the denial; they need the channel permission entry raised instead, so the new actionable message is misleading for exactly the permission-tier blocks it is meant to recover from.

Useful? React with 👍 / 👎.

{allowed} access."
),
None => format!(
"Raise the '{channel}' channel's agent-access tier (Settings → Agent access, or the \
`config.update_autonomy_settings` RPC / `[autonomy]` config), or accomplish the goal \
with a tool that needs only {allowed} access."
),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn session_forbidden_with_required_lists_reason_and_workaround() {
let msg = PolicyDenial::SessionForbidden {
tool: "run_script",
required: Some(PermissionLevel::Execute),
allowed: PermissionLevel::ReadOnly,
channel: "web",
}
.render();

assert!(msg.starts_with("Blocked: Tool 'run_script'"));
assert!(msg.contains("Reason:"));
assert!(msg.contains("requires Execute permission"));
assert!(msg.contains("Workaround:"));
assert!(msg.contains("agent-access tier"));
// The relay instruction is what keeps the agent from halting silently.
assert!(msg.contains("Relay this to the user"));
}

#[test]
fn session_forbidden_without_required_still_has_workaround() {
let msg = PolicyDenial::SessionForbidden {
tool: "run_script",
required: None,
allowed: PermissionLevel::ReadOnly,
channel: "cron",
}
.render();

assert!(msg.contains("not permitted"));
assert!(msg.contains("Workaround:"));
assert!(msg.contains("Relay this to the user"));
}

#[test]
fn permission_too_low_names_both_levels() {
let msg = PolicyDenial::PermissionTooLow {
tool: "shell",
required: PermissionLevel::Write,
allowed: PermissionLevel::ReadOnly,
channel: "web",
}
.render();

assert!(msg.contains("needs Write permission"));
assert!(msg.contains("only grants ReadOnly"));
assert!(msg.contains("Workaround:"));
}

#[test]
fn policy_denied_carries_reason_and_alternative() {
let msg = PolicyDenial::PolicyDenied {
tool: "run_script",
policy: "sandbox",
reason: "sandbox restriction",
}
.render();

assert!(msg.contains("denied by policy 'sandbox'"));
assert!(msg.contains("sandbox restriction"));
assert!(msg.contains("permitted alternative"));
assert!(msg.contains("Relay this to the user"));
}

#[test]
fn approval_required_suggests_approval_then_retry() {
let msg = PolicyDenial::ApprovalRequired {
tool: "send_email",
policy: "approval_gate",
reason: "outbound message needs sign-off",
}
.render();

assert!(msg.contains("requires approval under policy 'approval_gate'"));
assert!(msg.contains("approve this action"));
assert!(msg.contains("Relay this to the user"));
}
}
Loading