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 fable_5_1_is_authorized_and_dispatches_without_aliasing() {
126        let ctx = context_without_sonnet();
127        let policy = ctx.capabilities.orchestration.clone().unwrap();
128        let model = "anthropic/claude-fable-5-1";
129        assert!(policy.preflight(model).is_err());
130
131        let output = SubagentModelAuthorizeTool
132            .execute(json!({"model": model}), ctx)
133            .await
134            .unwrap();
135        let output: Value = serde_json::from_str(&output).unwrap();
136        assert_eq!(output["authorized_model"], model);
137        assert_eq!(output["scope"], "session");
138        assert_eq!(output["persisted"], false);
139        let authorized = policy
140            .resolve_and_authorize("sa_fable_5_1", Some(model))
141            .expect("newly authorized Fable 5.1 must dispatch");
142        assert_eq!(authorized.model.as_str(), model);
143        let route = crate::runtime::openai::resolve_route(model).unwrap();
144        assert_eq!(route.provider, "anthropic");
145        assert_eq!(route.model, "claude-fable-5-1");
146        assert!(policy.preflight("anthropic/claude-fable-5").is_err());
147        assert!(context_without_sonnet()
148            .capabilities
149            .orchestration
150            .unwrap()
151            .preflight(model)
152            .is_err());
153        for near_miss in [
154            "anthropic/claude-fable-5-10",
155            "anthropic/claude-fable-5-1-preview",
156        ] {
157            assert!(crate::orchestration::validate_user_authorizable_model(near_miss).is_err());
158        }
159    }
160
161    #[tokio::test]
162    async fn fable_5_2_is_authorized_and_dispatches_without_aliasing() {
163        let ctx = context_without_sonnet();
164        let policy = ctx.capabilities.orchestration.clone().unwrap();
165        let model = "anthropic/claude-fable-5-2";
166        assert!(policy.preflight(model).is_err());
167
168        let output = SubagentModelAuthorizeTool
169            .execute(json!({"model": model}), ctx)
170            .await
171            .unwrap();
172        let output: Value = serde_json::from_str(&output).unwrap();
173        assert_eq!(output["authorized_model"], model);
174        let authorized = policy
175            .resolve_and_authorize("sa_fable_5_2", Some(model))
176            .expect("newly authorized Fable 5.2 must dispatch");
177        assert_eq!(authorized.model.as_str(), model);
178        let route = crate::runtime::openai::resolve_route(model).unwrap();
179        assert_eq!(route.provider, "anthropic");
180        assert_eq!(route.model, "claude-fable-5-2");
181        // Authorizing 5.2 does not admit its neighbours.
182        assert!(policy.preflight("anthropic/claude-fable-5-1").is_err());
183        for near_miss in [
184            "anthropic/claude-fable-5-20",
185            "anthropic/claude-fable-5-2-preview",
186        ] {
187            assert!(crate::orchestration::validate_user_authorizable_model(near_miss).is_err());
188        }
189    }
190
191    #[tokio::test]
192    async fn already_authorized_model_is_idempotent_without_prompting() {
193        let ctx = create_tool_context();
194        let policy = ctx.capabilities.orchestration.clone().unwrap();
195        let before = policy.effective_choices();
196
197        let output = SubagentModelAuthorizeTool
198            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
199            .await
200            .unwrap();
201
202        let output: Value = serde_json::from_str(&output).unwrap();
203        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
204        assert_eq!(output["already_authorized"], true);
205        assert_eq!(policy.effective_choices(), before);
206    }
207
208    #[tokio::test]
209    async fn known_model_is_authorized_without_an_interactive_prompt() {
210        let ctx = context_without_sonnet();
211        let policy = ctx.capabilities.orchestration.clone().unwrap();
212        assert!(ctx.capabilities.secret_prompt.is_none());
213
214        let output = SubagentModelAuthorizeTool
215            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
216            .await
217            .unwrap();
218
219        let output: Value = serde_json::from_str(&output).unwrap();
220        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
221        assert!(policy
222            .effective_choices()
223            .contains(&"anthropic/claude-sonnet-4-6".to_owned()));
224        policy
225            .resolve_and_authorize("sa_user_requested", Some("anthropic/claude-sonnet-4-6"))
226            .expect("newly authorized exact model must dispatch");
227    }
228
229    #[tokio::test]
230    async fn authorization_is_exact_and_does_not_grant_sibling_models() {
231        let ctx = context_without_sonnet();
232        let policy = ctx.capabilities.orchestration.clone().unwrap();
233
234        SubagentModelAuthorizeTool
235            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
236            .await
237            .unwrap();
238
239        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
240        assert!(policy.preflight("anthropic/claude-fable-5").is_err());
241    }
242
243    #[tokio::test]
244    async fn authorization_is_not_present_in_a_fresh_session() {
245        let ctx = context_without_sonnet();
246        let policy = ctx.capabilities.orchestration.clone().unwrap();
247
248        SubagentModelAuthorizeTool
249            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
250            .await
251            .unwrap();
252        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
253
254        let fresh = context_without_sonnet();
255        let fresh_policy = fresh.capabilities.orchestration.unwrap();
256        assert!(fresh_policy
257            .preflight("anthropic/claude-sonnet-4-6")
258            .is_err());
259    }
260
261    #[tokio::test]
262    async fn oversized_model_identity_fails_before_policy_mutation() {
263        let ctx = context_without_sonnet();
264        let policy = ctx.capabilities.orchestration.clone().unwrap();
265        let before = policy.effective_choices();
266
267        let error = SubagentModelAuthorizeTool
268            .execute(
269                json!({"model": format!("anthropic/{}", "x".repeat(300))}),
270                ctx,
271            )
272            .await
273            .unwrap_err()
274            .to_string();
275
276        assert!(error.contains("256 bytes"), "{error}");
277        assert_eq!(policy.effective_choices(), before);
278    }
279
280    #[tokio::test]
281    async fn malformed_model_fails_closed_without_requesting_confirmation() {
282        let ctx = context_without_sonnet();
283        let policy = ctx.capabilities.orchestration.clone().unwrap();
284        let before = policy.effective_choices();
285
286        let error = SubagentModelAuthorizeTool
287            .execute(json!({"model": "claude-sonnet-4-6"}), ctx)
288            .await
289            .unwrap_err()
290            .to_string();
291
292        assert!(error.contains("invalid qualified model"), "{error}");
293        assert_eq!(policy.effective_choices(), before);
294    }
295
296    #[tokio::test]
297    async fn invented_model_fails_closed_without_requesting_confirmation() {
298        let ctx = context_without_sonnet();
299        let policy = ctx.capabilities.orchestration.clone().unwrap();
300        let before = policy.effective_choices();
301
302        let error = SubagentModelAuthorizeTool
303            .execute(json!({"model": "anthropic/claude-invented-99"}), ctx)
304            .await
305            .unwrap_err()
306            .to_string();
307
308        assert!(error.contains("known routable model"), "{error}");
309        assert_eq!(policy.effective_choices(), before);
310    }
311}