agent_orchestrator/resource/
runtime_policy.rs1use crate::cli_types::{
2 OrchestratorResource, ResourceKind, ResourceSpec, ResumeSpec, RunnerSpec, RuntimePolicySpec,
3};
4use crate::config::{
5 OrchestratorConfig, ResumeConfig, RunnerConfig, RunnerExecutorKind, RunnerPolicy,
6};
7use anyhow::{Result, anyhow};
8
9use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
10
11#[derive(Debug, Clone)]
12pub struct RuntimePolicyResource {
14 pub metadata: ResourceMetadata,
16 pub spec: RuntimePolicySpec,
18}
19
20impl Resource for RuntimePolicyResource {
21 fn kind(&self) -> ResourceKind {
22 ResourceKind::RuntimePolicy
23 }
24
25 fn name(&self) -> &str {
26 &self.metadata.name
27 }
28
29 fn validate(&self) -> Result<()> {
30 super::validate_resource_name(self.name())?;
31 if self.spec.runner.policy == "allowlist" {
32 let mut errors = Vec::new();
33 if self.spec.runner.allowed_shells.is_empty() {
34 errors.push("runner.allowed_shells cannot be empty when policy=allowlist");
35 }
36 if self.spec.runner.allowed_shell_args.is_empty() {
37 errors.push("runner.allowed_shell_args cannot be empty when policy=allowlist");
38 }
39 if !errors.is_empty() {
40 return Err(anyhow!(errors.join("; ")));
41 }
42 }
43 Ok(())
44 }
45
46 fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
47 use crate::crd::projection::{CrdProjectable, RuntimePolicyProjection};
48 let incoming_runner = runner_spec_to_config(&self.spec.runner);
49 let incoming_resume = ResumeConfig {
50 auto: self.spec.resume.auto,
51 };
52 let rp = RuntimePolicyProjection {
53 runner: incoming_runner,
54 resume: incoming_resume,
55 observability: crate::config::ObservabilityConfig::default(),
56 };
57 let spec_value = rp.to_cr_spec();
58 Ok(super::apply_to_store(
59 config,
60 "RuntimePolicy",
61 "runtime",
62 &self.metadata,
63 spec_value,
64 ))
65 }
66
67 fn to_yaml(&self) -> Result<String> {
68 super::manifest_yaml(
69 ResourceKind::RuntimePolicy,
70 &self.metadata,
71 ResourceSpec::RuntimePolicy(self.spec.clone()),
72 )
73 }
74
75 fn get_from_project(
76 config: &OrchestratorConfig,
77 name: &str,
78 project_id: Option<&str>,
79 ) -> Option<Self> {
80 use crate::config_ext::OrchestratorConfigExt as _;
81 let pid = project_id.unwrap_or(crate::config::DEFAULT_PROJECT_ID);
82 let rp = config.runtime_policy_for_project(pid);
83 let metadata = super::metadata_from_store(config, "RuntimePolicy", name, project_id);
84 Some(Self {
85 metadata,
86 spec: RuntimePolicySpec {
87 runner: runner_config_to_spec(&rp.runner),
88 resume: ResumeSpec {
89 auto: rp.resume.auto,
90 },
91 observability: serde_json::to_value(&rp.observability).ok(),
92 },
93 })
94 }
95
96 fn delete_from_project(
97 _config: &mut OrchestratorConfig,
98 _name: &str,
99 _project_id: Option<&str>,
100 ) -> bool {
101 false
103 }
104}
105
106pub(super) fn build_runtime_policy(resource: OrchestratorResource) -> Result<RegisteredResource> {
108 let OrchestratorResource {
109 kind,
110 metadata,
111 spec,
112 ..
113 } = resource;
114 if kind != ResourceKind::RuntimePolicy {
115 return Err(anyhow!("resource kind/spec mismatch for RuntimePolicy"));
116 }
117 match spec {
118 ResourceSpec::RuntimePolicy(spec) => {
119 Ok(RegisteredResource::RuntimePolicy(RuntimePolicyResource {
120 metadata,
121 spec,
122 }))
123 }
124 _ => Err(anyhow!("resource kind/spec mismatch for RuntimePolicy")),
125 }
126}
127
128pub(crate) fn runner_spec_to_config(spec: &RunnerSpec) -> RunnerConfig {
130 RunnerConfig {
131 shell: spec.shell.clone(),
132 shell_arg: spec.shell_arg.clone(),
133 policy: match spec.policy.as_str() {
134 "unsafe" | "legacy" => RunnerPolicy::Unsafe,
135 _ => RunnerPolicy::Allowlist,
136 },
137 executor: match spec.executor.as_str() {
138 "shell" => RunnerExecutorKind::Shell,
139 _ => RunnerExecutorKind::Shell,
140 },
141 allowed_shells: spec.allowed_shells.clone(),
142 allowed_shell_args: spec.allowed_shell_args.clone(),
143 env_allowlist: spec.env_allowlist.clone(),
144 redaction_patterns: spec.redaction_patterns.clone(),
145 }
146}
147
148pub(crate) fn runner_config_to_spec(config: &RunnerConfig) -> RunnerSpec {
150 RunnerSpec {
151 shell: config.shell.clone(),
152 shell_arg: config.shell_arg.clone(),
153 policy: match config.policy {
154 RunnerPolicy::Unsafe => "unsafe".to_string(),
155 RunnerPolicy::Allowlist => "allowlist".to_string(),
156 },
157 executor: match config.executor {
158 RunnerExecutorKind::Shell => "shell".to_string(),
159 },
160 allowed_shells: config.allowed_shells.clone(),
161 allowed_shell_args: config.allowed_shell_args.clone(),
162 env_allowlist: config.env_allowlist.clone(),
163 redaction_patterns: config.redaction_patterns.clone(),
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::cli_types::{ProjectSpec, ResourceMetadata, ResourceSpec};
171 use crate::resource::{API_VERSION, dispatch_resource};
172
173 use super::super::test_fixtures::{make_config, runtime_policy_manifest};
174
175 #[test]
176 fn runtime_policy_dispatch_and_kind() {
177 let resource =
178 dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
179 assert_eq!(resource.kind(), ResourceKind::RuntimePolicy);
180 assert_eq!(resource.name(), "runtime");
181 }
182
183 #[test]
184 fn runtime_policy_apply_unchanged_when_same() {
185 let mut config = make_config();
186 let r1 = dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
187 r1.apply(&mut config).expect("apply");
188 assert_eq!(
189 r1.apply(&mut config).expect("apply"),
190 ApplyResult::Unchanged
191 );
192 }
193
194 #[test]
195 fn runtime_policy_apply_configured_on_change() {
196 let mut config = make_config();
197 let r1 = dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
198 r1.apply(&mut config).expect("apply");
199
200 let mut manifest = runtime_policy_manifest();
202 if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
203 spec.runner.policy = "allowlist".to_string();
204 }
205 let r2 = dispatch_resource(manifest).expect("dispatch should succeed");
206 assert_eq!(
207 r2.apply(&mut config).expect("apply"),
208 ApplyResult::Configured
209 );
210 }
211
212 #[test]
213 fn runtime_policy_get_from_always_returns_some() {
214 let config = make_config();
215 let loaded = RuntimePolicyResource::get_from(&config, "runtime");
216 let loaded = loaded.expect("runtime policy should always be present");
217 assert_eq!(loaded.metadata.name, "runtime");
218 }
219
220 #[test]
221 fn runtime_policy_delete_returns_false() {
222 let mut config = make_config();
223 assert!(!RuntimePolicyResource::delete_from(&mut config, "runtime"));
224 }
225
226 #[test]
227 fn runtime_policy_to_yaml() {
228 let resource =
229 dispatch_resource(runtime_policy_manifest()).expect("dispatch should succeed");
230 let yaml = resource.to_yaml().expect("should serialize");
231 assert!(yaml.contains("kind: RuntimePolicy"));
232 assert!(yaml.contains("/bin/bash"));
233 }
234
235 #[test]
236 fn build_runtime_policy_rejects_wrong_kind() {
237 let resource = OrchestratorResource {
238 api_version: API_VERSION.to_string(),
239 kind: ResourceKind::RuntimePolicy,
240 metadata: ResourceMetadata {
241 name: "bad".to_string(),
242 project: None,
243 labels: None,
244 annotations: None,
245 },
246 spec: ResourceSpec::Project(ProjectSpec { description: None }),
247 };
248 let err = dispatch_resource(resource).expect_err("operation should fail");
249 assert!(err.to_string().contains("mismatch"));
250 }
251
252 #[test]
255 fn runner_spec_config_roundtrip() {
256 let spec = RunnerSpec {
257 shell: "/bin/zsh".to_string(),
258 shell_arg: "-c".to_string(),
259 policy: "allowlist".to_string(),
260 executor: "shell".to_string(),
261 allowed_shells: vec!["/bin/bash".to_string()],
262 allowed_shell_args: vec!["-c".to_string()],
263 env_allowlist: vec!["PATH".to_string()],
264 redaction_patterns: vec!["SECRET_.*".to_string()],
265 };
266
267 let config = runner_spec_to_config(&spec);
268 assert_eq!(config.shell, "/bin/zsh");
269 assert!(matches!(config.policy, RunnerPolicy::Allowlist));
270 assert!(matches!(config.executor, RunnerExecutorKind::Shell));
271 assert_eq!(config.allowed_shells, vec!["/bin/bash".to_string()]);
272 assert_eq!(config.env_allowlist, vec!["PATH".to_string()]);
273
274 let roundtripped = runner_config_to_spec(&config);
275 assert_eq!(roundtripped.shell, "/bin/zsh");
276 assert_eq!(roundtripped.policy, "allowlist");
277 assert_eq!(roundtripped.executor, "shell");
278 assert_eq!(roundtripped.allowed_shells, vec!["/bin/bash".to_string()]);
279 }
280
281 #[test]
282 fn validate_rejects_allowlist_with_empty_shells() {
283 let mut manifest = runtime_policy_manifest();
284 if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
285 spec.runner.policy = "allowlist".to_string();
286 spec.runner.allowed_shells = vec![];
287 spec.runner.allowed_shell_args = vec!["-lc".to_string()];
288 }
289 let resource = dispatch_resource(manifest).expect("dispatch should succeed");
290 let err = resource.validate().expect_err("operation should fail");
291 assert!(
292 err.to_string()
293 .contains("runner.allowed_shells cannot be empty")
294 );
295 }
296
297 #[test]
298 fn validate_rejects_allowlist_with_empty_shell_args() {
299 let mut manifest = runtime_policy_manifest();
300 if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
301 spec.runner.policy = "allowlist".to_string();
302 spec.runner.allowed_shells = vec!["/bin/bash".to_string()];
303 spec.runner.allowed_shell_args = vec![];
304 }
305 let resource = dispatch_resource(manifest).expect("dispatch should succeed");
306 let err = resource.validate().expect_err("operation should fail");
307 assert!(
308 err.to_string()
309 .contains("runner.allowed_shell_args cannot be empty")
310 );
311 }
312
313 #[test]
314 fn validate_accepts_allowlist_with_populated_lists() {
315 let mut manifest = runtime_policy_manifest();
316 if let ResourceSpec::RuntimePolicy(ref mut spec) = manifest.spec {
317 spec.runner.policy = "allowlist".to_string();
318 spec.runner.allowed_shells = vec!["/bin/bash".to_string()];
319 spec.runner.allowed_shell_args = vec!["-lc".to_string()];
320 }
321 let resource = dispatch_resource(manifest).expect("dispatch should succeed");
322 assert!(resource.validate().is_ok());
323 }
324
325 #[test]
326 fn validate_accepts_unsafe_with_empty_lists() {
327 let manifest = runtime_policy_manifest();
328 let resource = dispatch_resource(manifest).expect("dispatch should succeed");
329 assert!(resource.validate().is_ok());
330 }
331
332 #[test]
333 fn runner_spec_unsafe_policy() {
334 let spec = RunnerSpec {
335 shell: "/bin/sh".to_string(),
336 shell_arg: "-c".to_string(),
337 policy: "unsafe".to_string(),
338 executor: "shell".to_string(),
339 allowed_shells: vec![],
340 allowed_shell_args: vec![],
341 env_allowlist: vec![],
342 redaction_patterns: vec![],
343 };
344 let config = runner_spec_to_config(&spec);
345 assert!(matches!(config.policy, RunnerPolicy::Unsafe));
346 let back = runner_config_to_spec(&config);
347 assert_eq!(back.policy, "unsafe");
348 }
349
350 #[test]
351 fn runner_spec_legacy_policy_normalizes_to_unsafe() {
352 let spec = RunnerSpec {
353 shell: "/bin/sh".to_string(),
354 shell_arg: "-c".to_string(),
355 policy: "legacy".to_string(),
356 executor: "shell".to_string(),
357 allowed_shells: vec![],
358 allowed_shell_args: vec![],
359 env_allowlist: vec![],
360 redaction_patterns: vec![],
361 };
362 let config = runner_spec_to_config(&spec);
363 assert!(matches!(config.policy, RunnerPolicy::Unsafe));
364 let back = runner_config_to_spec(&config);
365 assert_eq!(back.policy, "unsafe");
366 }
367
368 #[test]
369 fn runner_spec_unknown_policy_falls_back_to_allowlist() {
370 let spec = RunnerSpec {
371 shell: "/bin/sh".to_string(),
372 shell_arg: "-c".to_string(),
373 policy: "unknown".to_string(),
374 executor: "shell".to_string(),
375 allowed_shells: vec![],
376 allowed_shell_args: vec![],
377 env_allowlist: vec![],
378 redaction_patterns: vec![],
379 };
380 let config = runner_spec_to_config(&spec);
381 assert!(matches!(config.policy, RunnerPolicy::Allowlist));
382 }
383
384 #[test]
385 fn default_runner_spec_produces_allowlist() {
386 let json = r#"{"shell":"/bin/bash"}"#;
387 let spec: RunnerSpec =
388 serde_json::from_str(json).expect("runner spec json should deserialize");
389 assert_eq!(spec.policy, "allowlist");
390 assert!(!spec.allowed_shells.is_empty());
391 assert!(!spec.allowed_shell_args.is_empty());
392 let config = runner_spec_to_config(&spec);
393 assert!(matches!(config.policy, RunnerPolicy::Allowlist));
394 }
395}