agent_engine/tools/subagent/
authorize_model.rs1use 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 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}