Skip to main content

agent_engine/tools/subagent/
authorize_model.rs

1//! Session-scoped worker-model authorization.
2use super::super::{Tool, ToolContext};
3use crate::{Result, RuntimeError};
4use serde_json::{json, Value};
5
6pub struct SubagentModelAuthorizeTool;
7
8#[async_trait::async_trait]
9impl Tool for SubagentModelAuthorizeTool {
10    fn origin(&self) -> crate::tools::ToolOrigin {
11        crate::tools::ToolOrigin::Builtin
12    }
13
14    fn name(&self) -> &str {
15        "subagent_model_authorize"
16    }
17
18    fn description(&self) -> &str {
19        "Authorize one exact qualified, locally known model for subagent use in this session. The grant is session-only: it does not change the foreground model or persist favorites. No sudo or interactive confirmation is required."
20    }
21
22    fn parameters(&self) -> Value {
23        json!({
24            "type": "object",
25            "properties": {
26                "model": {
27                    "type": "string",
28                    "maxLength": 256,
29                    "description": "Exact runtime-qualified model identity, for example anthropic/claude-sonnet-4-6."
30                }
31            },
32            "required": ["model"],
33            "additionalProperties": false
34        })
35    }
36
37    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
38        let model = params["model"]
39            .as_str()
40            .ok_or_else(|| RuntimeError::Tool("Missing 'model' parameter".into()))?;
41        if model.len() > 256 {
42            return Err(RuntimeError::Tool(
43                "worker-model authorization denied: model identity exceeds 256 bytes".into(),
44            ));
45        }
46        let model = crate::orchestration::validate_user_authorizable_model(model)
47            .map_err(RuntimeError::Tool)?;
48        let policy = ctx
49            .capabilities
50            .orchestration
51            .as_ref()
52            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
53        if policy
54            .effective_choices()
55            .iter()
56            .any(|choice| choice == model.as_str())
57        {
58            return Ok(json!({
59                "authorized_model": model.as_str(),
60                "already_authorized": true,
61                "scope": "session",
62                "persisted": false,
63                "foreground_model": policy.foreground_model(),
64                "models": policy.effective_choices()
65            })
66            .to_string());
67        }
68        policy
69            .grant_worker_model(model.as_str())
70            .map_err(RuntimeError::Tool)?;
71
72        Ok(json!({
73            "authorized_model": model.as_str(),
74            "scope": "session",
75            "persisted": false,
76            "foreground_model": policy.foreground_model(),
77            "models": policy.effective_choices()
78        })
79        .to_string())
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::tools::test_helpers::create_tool_context;
87
88    fn context_without_sonnet() -> ToolContext {
89        let mut ctx = create_tool_context();
90        let foreground = agent_core::prompt::QualifiedModelId::parse("anthropic/claude-opus-4-6")
91            .expect("test foreground is qualified");
92        ctx.capabilities.orchestration = Some(std::sync::Arc::new(
93            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
94                .expect("test foreground is routable"),
95        ));
96        ctx
97    }
98
99    #[tokio::test]
100    async fn known_model_is_added_for_this_session() {
101        let ctx = context_without_sonnet();
102        let policy = ctx.capabilities.orchestration.clone().unwrap();
103        assert!(!policy
104            .effective_choices()
105            .contains(&"anthropic/claude-sonnet-4-6".to_owned()));
106
107        let output = SubagentModelAuthorizeTool
108            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
109            .await
110            .unwrap();
111
112        let output: Value = serde_json::from_str(&output).unwrap();
113        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
114        assert_eq!(output["scope"], "session");
115        assert_eq!(output["persisted"], false);
116        assert!(policy
117            .effective_choices()
118            .contains(&"anthropic/claude-sonnet-4-6".to_owned()));
119        policy
120            .resolve_and_authorize("sa_user_requested", Some("anthropic/claude-sonnet-4-6"))
121            .expect("newly authorized exact model must dispatch");
122    }
123
124    #[tokio::test]
125    async fn already_authorized_model_is_idempotent_without_prompting() {
126        let ctx = create_tool_context();
127        let policy = ctx.capabilities.orchestration.clone().unwrap();
128        let before = policy.effective_choices();
129
130        let output = SubagentModelAuthorizeTool
131            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
132            .await
133            .unwrap();
134
135        let output: Value = serde_json::from_str(&output).unwrap();
136        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
137        assert_eq!(output["already_authorized"], true);
138        assert_eq!(policy.effective_choices(), before);
139    }
140
141    #[tokio::test]
142    async fn known_model_is_authorized_without_an_interactive_prompt() {
143        let ctx = context_without_sonnet();
144        let policy = ctx.capabilities.orchestration.clone().unwrap();
145        assert!(ctx.capabilities.secret_prompt.is_none());
146
147        let output = SubagentModelAuthorizeTool
148            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
149            .await
150            .unwrap();
151
152        let output: Value = serde_json::from_str(&output).unwrap();
153        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
154        assert!(policy
155            .effective_choices()
156            .contains(&"anthropic/claude-sonnet-4-6".to_owned()));
157        policy
158            .resolve_and_authorize("sa_user_requested", Some("anthropic/claude-sonnet-4-6"))
159            .expect("newly authorized exact model must dispatch");
160    }
161
162    #[tokio::test]
163    async fn authorization_is_exact_and_does_not_grant_sibling_models() {
164        let ctx = context_without_sonnet();
165        let policy = ctx.capabilities.orchestration.clone().unwrap();
166
167        SubagentModelAuthorizeTool
168            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
169            .await
170            .unwrap();
171
172        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
173        assert!(policy.preflight("anthropic/claude-fable-5").is_err());
174    }
175
176    #[tokio::test]
177    async fn authorization_is_not_present_in_a_fresh_session() {
178        let ctx = context_without_sonnet();
179        let policy = ctx.capabilities.orchestration.clone().unwrap();
180
181        SubagentModelAuthorizeTool
182            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
183            .await
184            .unwrap();
185        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
186
187        let fresh = context_without_sonnet();
188        let fresh_policy = fresh.capabilities.orchestration.unwrap();
189        assert!(fresh_policy
190            .preflight("anthropic/claude-sonnet-4-6")
191            .is_err());
192    }
193
194    #[tokio::test]
195    async fn oversized_model_identity_fails_before_policy_mutation() {
196        let ctx = context_without_sonnet();
197        let policy = ctx.capabilities.orchestration.clone().unwrap();
198        let before = policy.effective_choices();
199
200        let error = SubagentModelAuthorizeTool
201            .execute(
202                json!({"model": format!("anthropic/{}", "x".repeat(300))}),
203                ctx,
204            )
205            .await
206            .unwrap_err()
207            .to_string();
208
209        assert!(error.contains("256 bytes"), "{error}");
210        assert_eq!(policy.effective_choices(), before);
211    }
212
213    #[tokio::test]
214    async fn malformed_model_fails_closed_without_requesting_confirmation() {
215        let ctx = context_without_sonnet();
216        let policy = ctx.capabilities.orchestration.clone().unwrap();
217        let before = policy.effective_choices();
218
219        let error = SubagentModelAuthorizeTool
220            .execute(json!({"model": "claude-sonnet-4-6"}), ctx)
221            .await
222            .unwrap_err()
223            .to_string();
224
225        assert!(error.contains("invalid qualified model"), "{error}");
226        assert_eq!(policy.effective_choices(), before);
227    }
228
229    #[tokio::test]
230    async fn invented_model_fails_closed_without_requesting_confirmation() {
231        let ctx = context_without_sonnet();
232        let policy = ctx.capabilities.orchestration.clone().unwrap();
233        let before = policy.effective_choices();
234
235        let error = SubagentModelAuthorizeTool
236            .execute(json!({"model": "anthropic/claude-invented-99"}), ctx)
237            .await
238            .unwrap_err()
239            .to_string();
240
241        assert!(error.contains("known routable model"), "{error}");
242        assert_eq!(policy.effective_choices(), before);
243    }
244}