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