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 already_authorized_model_is_idempotent_without_prompting() {
163        let ctx = create_tool_context();
164        let policy = ctx.capabilities.orchestration.clone().unwrap();
165        let before = policy.effective_choices();
166
167        let output = SubagentModelAuthorizeTool
168            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
169            .await
170            .unwrap();
171
172        let output: Value = serde_json::from_str(&output).unwrap();
173        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
174        assert_eq!(output["already_authorized"], true);
175        assert_eq!(policy.effective_choices(), before);
176    }
177
178    #[tokio::test]
179    async fn known_model_is_authorized_without_an_interactive_prompt() {
180        let ctx = context_without_sonnet();
181        let policy = ctx.capabilities.orchestration.clone().unwrap();
182        assert!(ctx.capabilities.secret_prompt.is_none());
183
184        let output = SubagentModelAuthorizeTool
185            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
186            .await
187            .unwrap();
188
189        let output: Value = serde_json::from_str(&output).unwrap();
190        assert_eq!(output["authorized_model"], "anthropic/claude-sonnet-4-6");
191        assert!(policy
192            .effective_choices()
193            .contains(&"anthropic/claude-sonnet-4-6".to_owned()));
194        policy
195            .resolve_and_authorize("sa_user_requested", Some("anthropic/claude-sonnet-4-6"))
196            .expect("newly authorized exact model must dispatch");
197    }
198
199    #[tokio::test]
200    async fn authorization_is_exact_and_does_not_grant_sibling_models() {
201        let ctx = context_without_sonnet();
202        let policy = ctx.capabilities.orchestration.clone().unwrap();
203
204        SubagentModelAuthorizeTool
205            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
206            .await
207            .unwrap();
208
209        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
210        assert!(policy.preflight("anthropic/claude-fable-5").is_err());
211    }
212
213    #[tokio::test]
214    async fn authorization_is_not_present_in_a_fresh_session() {
215        let ctx = context_without_sonnet();
216        let policy = ctx.capabilities.orchestration.clone().unwrap();
217
218        SubagentModelAuthorizeTool
219            .execute(json!({"model": "anthropic/claude-sonnet-4-6"}), ctx)
220            .await
221            .unwrap();
222        assert!(policy.preflight("anthropic/claude-sonnet-4-6").is_ok());
223
224        let fresh = context_without_sonnet();
225        let fresh_policy = fresh.capabilities.orchestration.unwrap();
226        assert!(fresh_policy
227            .preflight("anthropic/claude-sonnet-4-6")
228            .is_err());
229    }
230
231    #[tokio::test]
232    async fn oversized_model_identity_fails_before_policy_mutation() {
233        let ctx = context_without_sonnet();
234        let policy = ctx.capabilities.orchestration.clone().unwrap();
235        let before = policy.effective_choices();
236
237        let error = SubagentModelAuthorizeTool
238            .execute(
239                json!({"model": format!("anthropic/{}", "x".repeat(300))}),
240                ctx,
241            )
242            .await
243            .unwrap_err()
244            .to_string();
245
246        assert!(error.contains("256 bytes"), "{error}");
247        assert_eq!(policy.effective_choices(), before);
248    }
249
250    #[tokio::test]
251    async fn malformed_model_fails_closed_without_requesting_confirmation() {
252        let ctx = context_without_sonnet();
253        let policy = ctx.capabilities.orchestration.clone().unwrap();
254        let before = policy.effective_choices();
255
256        let error = SubagentModelAuthorizeTool
257            .execute(json!({"model": "claude-sonnet-4-6"}), ctx)
258            .await
259            .unwrap_err()
260            .to_string();
261
262        assert!(error.contains("invalid qualified model"), "{error}");
263        assert_eq!(policy.effective_choices(), before);
264    }
265
266    #[tokio::test]
267    async fn invented_model_fails_closed_without_requesting_confirmation() {
268        let ctx = context_without_sonnet();
269        let policy = ctx.capabilities.orchestration.clone().unwrap();
270        let before = policy.effective_choices();
271
272        let error = SubagentModelAuthorizeTool
273            .execute(json!({"model": "anthropic/claude-invented-99"}), ctx)
274            .await
275            .unwrap_err()
276            .to_string();
277
278        assert!(error.contains("known routable model"), "{error}");
279        assert_eq!(policy.effective_choices(), before);
280    }
281}