astrid-runtime/astrid · error
tool_describe interceptor denied
Error message
tool_describe interceptor denied: {reason} What it means
After a tool_describe call passes through interceptors, a Deny result whose reason is not the recognized "unknown action" marker is treated as a genuine policy refusal and surfaces as this error. Only unknown-action denies are translated into an empty (Some([])) tool list; every other deny reason is a real failure for the caller to see.
Solutions
- Read the {reason} in the message to identify which interceptor denied the call and why.
- Adjust the interceptor/policy so tool_describe is permitted for this capsule (add it to the allowlist or fix the rule).
- If the deny is intentional, handle it in your code rather than calling describe, or map expected reasons explicitly.
- Check interceptor registration order — an early catch-all deny may be shadowing allowed calls.
Example fix
// before: blanket deny in an interceptor
if !caller.is_admin() {
return InterceptResult::Deny { reason: "admin-only".into() };
}
// after: allow describe, deny only mutations
if action != "tool_describe" && !caller.is_admin() {
return InterceptResult::Deny { reason: "admin-only".into() };
} Defensive patterns
Strategy: try-catch
Validate before calling
// confirm the interceptor policy allows tool_describe before calling
if !policy.allows(caller, Action::ToolDescribe, capsule_id) {
eprintln!("policy will deny tool_describe; adjust rules first");
} Type guard
fn is_genuine_deny(reason: &str) -> bool {
!is_unknown_action(reason)
} Try / catch
match describe_loaded_capsule_status(&capsule).await {
Ok(status) => { /* ... */ }
Err(e) if e.to_string().contains("interceptor denied") => {
let reason = e.to_string();
eprintln!("describe refused by policy: {reason}");
}
Err(e) => return Err(e),
} Prevention
- Whitelist tool_describe explicitly in interceptor policy.
- Avoid catch-all Deny rules early in the interceptor chain.
- Use the reserved unknown-action reason only for unknown actions.
- Log deny reasons at the interceptor to make refusals diagnosable.
- Test fan-out describe with policies enabled.
When it happens
Trigger: describe_loaded_capsule_status (or describe_loaded_capsule_status_for / fan-out paths) runs a tool_describe request and an interceptor returns InterceptResult::Deny with an arbitrary reason string not matching is_unknown_action.
Common situations: A policy interceptor (permissions/allowlist middleware) blocks tool_describe for the capsule or caller; a custom interceptor returns Deny with a bespoke reason; a hook misconfigured to deny describe calls.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- an incomplete capsule authority update exists at
- astrid-build failed with exit code
- astrid-build produced no .capsule archive.
- authority receipt has an empty capsule id
- candidate generation for
AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09).
Data as JSON: /api/errors/87ce7a46000d08bb.
Report an issue: GitHub.
Appendix: source
Thrown at crates/astrid-capsule/src/tool_discovery.rs:141
// Pool-less run-loop capsule: the interceptor path isn't available, so
// the tool surface is UNKNOWN — not empty. Signal absent (`None`) so the
// caller lets the describe fan-out supply it (#1198), rather than
// injecting `[]` and suppressing the fan-out.
Err(e) if is_unsupported(&e) => return Ok(None),
Err(e) => return Err(anyhow::anyhow!("tool_describe interceptor failed: {e}")),
};
let payload = match result {
InterceptResult::Continue(bytes) | InterceptResult::Final(bytes) => bytes,
// A capsule that CAN run interceptors but has no `#[astrid::tool]` arm
// (e.g. the broker) genuinely has zero static tools: captured-empty
// (`Some([])`), NOT absent — there is nothing for a fan-out to supply.
InterceptResult::Deny { reason } if is_unknown_action(&reason) => {
return Ok(Some(Vec::new()));
},
// Any other deny is a genuine refusal — surface it.
InterceptResult::Deny { reason } => {
anyhow::bail!("tool_describe interceptor denied: {reason}");
},
};
if payload.is_empty() {
return Ok(Some(Vec::new()));
}
parse_tool_descriptors(&payload).map(Some)
}
/// Parse the `tools` array out of a `tool_describe` descriptor payload
/// (`{ "tools": [ {name, description, input_schema}, ... ], "description": "..." }`).
///
/// Deserializes straight into a typed wrapper rather than walking a generic
/// `serde_json::Value` — no intermediate allocation or clone. A missing
/// `tools` key defaults to empty (a non-tool payload), while a present-but-
/// malformed `tools` array is a hard error.
fn parse_tool_descriptors(payload: &[u8]) -> anyhow::Result<Vec<ToolDescriptor>> {View on GitHub (pinned to affd8760f4)