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 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}