mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 \
|
||||
{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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user