1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::TuiError;
16use crate::security::validate::validate_argument;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct HarnessCapability {
22 pub model: bool,
23 pub reasoning_effort: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ModelAssignment {
48 pub agent_id: String,
49 pub harness: String,
50 pub model: Option<String>,
51 #[serde(default)]
52 pub model_provider: Option<String>,
53 #[serde(default)]
54 pub model_fallback_from: Option<String>,
55 #[serde(default)]
56 pub model_warning: Option<String>,
57 pub reasoning_effort: Option<String>,
58 pub model_source: String,
59 pub reasoning_source: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ModelAssignments {
66 pub manifest_version: u32,
67 pub generated_by: String,
68 pub policy_sha256: String,
69 pub capabilities: HashMap<String, HarnessCapability>,
70 pub assignments: Vec<ModelAssignment>,
71}
72
73impl ModelAssignments {
74 pub fn for_agent(&self, agent_id: &str) -> Vec<&ModelAssignment> {
76 self.assignments
77 .iter()
78 .filter(|a| a.agent_id == agent_id)
79 .collect()
80 }
81}
82
83pub const CAPABLE_HARNESSES: &[&str] = &["codex", "claude-code", "cursor"];
87
88pub const REASONING_EFFORTS: &[&str] = &[
93 "auto", "none", "minimal", "low", "medium", "high", "xhigh", "max",
94];
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ModelScope {
99 All,
101 Provider(String),
103 Role(String),
105 Agent(String),
107}
108
109impl ModelScope {
110 pub fn to_flag(&self) -> String {
112 match self {
113 ModelScope::All => "all".to_string(),
114 ModelScope::Provider(id) => format!("provider={id}"),
115 ModelScope::Role(id) => format!("role={id}"),
116 ModelScope::Agent(id) => format!("agent={id}"),
117 }
118 }
119
120 pub fn display(&self) -> String {
122 match self {
123 ModelScope::All => "All agents".to_string(),
124 ModelScope::Provider(id) => format!("Provider: {id}"),
125 ModelScope::Role(id) => format!("Role: {id}"),
126 ModelScope::Agent(id) => format!("Agent: {id}"),
127 }
128 }
129}
130
131#[derive(Debug, Clone)]
134pub struct ModelPolicyCommand {
135 pub scope: ModelScope,
136 pub harness: String,
137 pub model: Option<String>,
139 pub reasoning: Option<String>,
141 pub dry_run: bool,
142}
143
144impl ModelPolicyCommand {
145 pub fn validate(&self) -> Result<(), TuiError> {
149 match &self.scope {
150 ModelScope::All => {}
151 ModelScope::Provider(id) | ModelScope::Role(id) | ModelScope::Agent(id) => {
152 validate_argument(id)?;
153 }
154 }
155 validate_argument(&self.harness)?;
156 if let Some(model) = &self.model {
157 validate_argument(model)?;
158 }
159 if let Some(reasoning) = &self.reasoning {
160 validate_argument(reasoning)?;
161 }
162 if self.model.is_none() && self.reasoning.is_none() {
163 return Err(TuiError::ValidationRejected {
164 value: "<empty>".to_string(),
165 rule: "set a model and/or a reasoning effort".to_string(),
166 });
167 }
168 Ok(())
169 }
170
171 pub fn to_args(&self) -> Vec<String> {
173 let mut args = vec![
174 "set".to_string(),
175 "--scope".to_string(),
176 self.scope.to_flag(),
177 "--harness".to_string(),
178 self.harness.clone(),
179 ];
180 if let Some(model) = &self.model {
181 args.push("--model".to_string());
182 args.push(model.clone());
183 }
184 if let Some(reasoning) = &self.reasoning {
185 args.push("--reasoning".to_string());
186 args.push(reasoning.clone());
187 }
188 if self.dry_run {
189 args.push("--dry-run".to_string());
190 }
191 args
192 }
193
194 pub fn display_command(&self) -> String {
196 format!("node scripts/model-policy.mjs {}", self.to_args().join(" "))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn cmd(scope: ModelScope) -> ModelPolicyCommand {
205 ModelPolicyCommand {
206 scope,
207 harness: "codex".to_string(),
208 model: Some("gpt-5.4".to_string()),
209 reasoning: Some("high".to_string()),
210 dry_run: true,
211 }
212 }
213
214 #[test]
215 fn to_args_all_scope() {
216 let args = cmd(ModelScope::All).to_args();
217 assert_eq!(
218 args,
219 vec![
220 "set",
221 "--scope",
222 "all",
223 "--harness",
224 "codex",
225 "--model",
226 "gpt-5.4",
227 "--reasoning",
228 "high",
229 "--dry-run",
230 ]
231 );
232 }
233
234 #[test]
235 fn to_args_role_scope_without_dry_run() {
236 let mut c = cmd(ModelScope::Role("cloud-security-engineer".to_string()));
237 c.dry_run = false;
238 c.reasoning = None;
239 let args = c.to_args();
240 assert!(args.contains(&"role=cloud-security-engineer".to_string()));
241 assert!(!args.contains(&"--dry-run".to_string()));
242 assert!(!args.contains(&"--reasoning".to_string()));
243 }
244
245 #[test]
246 fn to_args_provider_and_agent_scopes() {
247 assert!(cmd(ModelScope::Provider("gcp".to_string()))
248 .to_args()
249 .contains(&"provider=gcp".to_string()));
250 assert!(cmd(ModelScope::Agent(
251 "gcp-firebase-developer-agent".to_string()
252 ))
253 .to_args()
254 .contains(&"agent=gcp-firebase-developer-agent".to_string()));
255 }
256
257 #[test]
258 fn validate_accepts_safe_command() {
259 assert!(cmd(ModelScope::All).validate().is_ok());
260 }
261
262 #[test]
263 fn validate_rejects_metachar_in_model() {
264 let mut c = cmd(ModelScope::All);
265 c.model = Some("gpt-5.4; rm -rf /".to_string());
266 assert!(c.validate().is_err());
267 }
268
269 #[test]
270 fn validate_rejects_metachar_in_scope_id() {
271 let c = cmd(ModelScope::Role("role$(cmd)".to_string()));
272 assert!(c.validate().is_err());
273 }
274
275 #[test]
276 fn validate_rejects_empty_field_set() {
277 let mut c = cmd(ModelScope::All);
278 c.model = None;
279 c.reasoning = None;
280 assert!(c.validate().is_err());
281 }
282
283 #[test]
284 fn display_command_format() {
285 let display = cmd(ModelScope::All).display_command();
286 assert!(display.starts_with("node scripts/model-policy.mjs set"));
287 assert!(display.contains("--scope all"));
288 }
289
290 #[test]
291 fn assignments_for_agent_filters() {
292 let doc = ModelAssignments {
293 manifest_version: 1,
294 generated_by: "test".to_string(),
295 policy_sha256: "0".repeat(64),
296 capabilities: HashMap::new(),
297 assignments: vec![
298 ModelAssignment {
299 agent_id: "a".to_string(),
300 harness: "codex".to_string(),
301 model: Some("gpt-5.4".to_string()),
302 model_provider: None,
303 model_fallback_from: None,
304 model_warning: None,
305 reasoning_effort: Some("high".to_string()),
306 model_source: "all".to_string(),
307 reasoning_source: "all".to_string(),
308 },
309 ModelAssignment {
310 agent_id: "b".to_string(),
311 harness: "codex".to_string(),
312 model: None,
313 model_provider: None,
314 model_fallback_from: None,
315 model_warning: None,
316 reasoning_effort: None,
317 model_source: "default".to_string(),
318 reasoning_source: "default".to_string(),
319 },
320 ],
321 };
322 let for_a = doc.for_agent("a");
323 assert_eq!(for_a.len(), 1);
324 assert_eq!(for_a[0].model.as_deref(), Some("gpt-5.4"));
325 assert!(doc.for_agent("missing").is_empty());
326 }
327
328 #[test]
329 fn parses_generated_assignments_shape() {
330 let json = r#"{
331 "manifest_version": 1,
332 "generated_by": "scripts/model-policy.mjs",
333 "policy_sha256": "abc",
334 "capabilities": {
335 "codex": { "model": true, "reasoning_effort": true }
336 },
337 "assignments": [
338 {
339 "agent_id": "x",
340 "harness": "codex",
341 "model": null,
342 "model_provider": null,
343 "reasoning_effort": "high",
344 "model_source": "default",
345 "reasoning_source": "all"
346 }
347 ]
348 }"#;
349 let parsed: ModelAssignments = serde_json::from_str(json).expect("parse");
350 assert_eq!(parsed.assignments.len(), 1);
351 assert!(parsed.assignments[0].model.is_none());
352 assert!(parsed.assignments[0].model_fallback_from.is_none());
356 assert!(parsed.assignments[0].model_warning.is_none());
357 }
358
359 #[test]
360 fn parses_assignment_with_lifecycle_warning() {
361 let json = r#"{
362 "manifest_version": 1,
363 "generated_by": "scripts/model-policy.mjs",
364 "policy_sha256": "abc",
365 "capabilities": {
366 "codex": { "model": true, "reasoning_effort": true }
367 },
368 "assignments": [
369 {
370 "agent_id": "x",
371 "harness": "codex",
372 "model": "gpt-5.5",
373 "model_provider": null,
374 "model_fallback_from": "gpt-5-2025-08-07",
375 "model_warning": "model \"gpt-5-2025-08-07\" was retired by the provider — projecting documented successor \"gpt-5.5\"; migrate the policy rule",
376 "reasoning_effort": "high",
377 "model_source": "agent:x",
378 "reasoning_source": "agent:x"
379 }
380 ]
381 }"#;
382 let parsed: ModelAssignments = serde_json::from_str(json).expect("parse");
383 let a = &parsed.assignments[0];
384 assert_eq!(a.model.as_deref(), Some("gpt-5.5"));
385 assert_eq!(a.model_fallback_from.as_deref(), Some("gpt-5-2025-08-07"));
386 assert!(a.model_warning.as_deref().unwrap().contains("was retired"));
387 }
388}