{"record":{"id":"87ce7a46000d08bb","repo":"astrid-runtime/astrid","slug":"tool-describe-interceptor-denied-reason","errorCode":null,"errorMessage":"tool_describe interceptor denied: {reason}","messagePattern":"tool_describe interceptor denied: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-capsule/src/tool_discovery.rs","lineNumber":141,"sourceCode":"        // Pool-less run-loop capsule: the interceptor path isn't available, so\n        // the tool surface is UNKNOWN — not empty. Signal absent (`None`) so the\n        // caller lets the describe fan-out supply it (#1198), rather than\n        // injecting `[]` and suppressing the fan-out.\n        Err(e) if is_unsupported(&e) => return Ok(None),\n        Err(e) => return Err(anyhow::anyhow!(\"tool_describe interceptor failed: {e}\")),\n    };\n\n    let payload = match result {\n        InterceptResult::Continue(bytes) | InterceptResult::Final(bytes) => bytes,\n        // A capsule that CAN run interceptors but has no `#[astrid::tool]` arm\n        // (e.g. the broker) genuinely has zero static tools: captured-empty\n        // (`Some([])`), NOT absent — there is nothing for a fan-out to supply.\n        InterceptResult::Deny { reason } if is_unknown_action(&reason) => {\n            return Ok(Some(Vec::new()));\n        },\n        // Any other deny is a genuine refusal — surface it.\n        InterceptResult::Deny { reason } => {\n            anyhow::bail!(\"tool_describe interceptor denied: {reason}\");\n        },\n    };\n\n    if payload.is_empty() {\n        return Ok(Some(Vec::new()));\n    }\n\n    parse_tool_descriptors(&payload).map(Some)\n}\n\n/// Parse the `tools` array out of a `tool_describe` descriptor payload\n/// (`{ \"tools\": [ {name, description, input_schema}, ... ], \"description\": \"...\" }`).\n///\n/// Deserializes straight into a typed wrapper rather than walking a generic\n/// `serde_json::Value` — no intermediate allocation or clone. A missing\n/// `tools` key defaults to empty (a non-tool payload), while a present-but-\n/// malformed `tools` array is a hard error.\nfn parse_tool_descriptors(payload: &[u8]) -> anyhow::Result<Vec<ToolDescriptor>> {","sourceCodeStart":123,"sourceCodeEnd":159,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-capsule/src/tool_discovery.rs#L123-L159","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: blanket deny in an interceptor\nif !caller.is_admin() {\n    return InterceptResult::Deny { reason: \"admin-only\".into() };\n}\n\n// after: allow describe, deny only mutations\nif action != \"tool_describe\" && !caller.is_admin() {\n    return InterceptResult::Deny { reason: \"admin-only\".into() };\n}","handlingStrategy":"try-catch","validationCode":"// confirm the interceptor policy allows tool_describe before calling\nif !policy.allows(caller, Action::ToolDescribe, capsule_id) {\n    eprintln!(\"policy will deny tool_describe; adjust rules first\");\n}","typeGuard":"fn is_genuine_deny(reason: &str) -> bool {\n    !is_unknown_action(reason)\n}","tryCatchPattern":"match describe_loaded_capsule_status(&capsule).await {\n    Ok(status) => { /* ... */ }\n    Err(e) if e.to_string().contains(\"interceptor denied\") => {\n        let reason = e.to_string();\n        eprintln!(\"describe refused by policy: {reason}\");\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["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."],"tags":["interceptor","policy-denied","tool-describe","capsule"],"backgroundTag":"permission-denied","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-17T15:17:12.973Z"}