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

  1. Read the {reason} in the message to identify which interceptor denied the call and why.
  2. Adjust the interceptor/policy so tool_describe is permitted for this capsule (add it to the allowlist or fix the rule).
  3. If the deny is intentional, handle it in your code rather than calling describe, or map expected reasons explicitly.
  4. 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

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


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)